/** * UNO * Pokemon Showdown - http://pokemonshowdown.com/ * * This plugin allows rooms to run games of scripted UNO * * @license MIT license */ import { Utils } from '../../lib'; type Color = 'Green' | 'Yellow' | 'Red' | 'Blue' | 'Black'; interface Card { value: string; color: Color; changedColor?: Color; name: string; } const MAX_TIME = 60; // seconds const rgbGradients: { [k in Color]: string } = { 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: { [k in Color]: string } = { 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;'; function cardHTML(card: Card, fullsize: boolean) { let surface = card.value.replace(/[^A-Z0-9+]/g, ""); const background = rgbGradients[card.color]; if (surface === 'R') surface = ''; return ``; } function createDeck() { const colors: Color[] = ['Red', 'Blue', 'Green', 'Yellow']; const values = ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'Reverse', 'Skip', '+2']; const basic: Card[] = []; for (const color of colors) { basic.push(...values.map(v => { const c: Card = { value: v, 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 => { const c: Card = { color: colors[v], value: '0', name: `${colors[v]} 0` }; return c; }), // Wild cards ...[0, 1, 2, 3].map(v => { const c: Card = { color: 'Black', value: 'Wild', name: 'Wild' }; return c; }), // Wild +4 cards ...[0, 1, 2, 3].map(v => { const c: Card = { color: 'Black', value: '+4', name: 'Wild +4' }; return c; }), ]; // 108 cards } export class UNO extends Rooms.RoomGame { override readonly gameid = 'uno' as ID; override title = 'UNO'; override readonly allowRenames = true; override timer: NodeJS.Timeout | null = null; maxTime = MAX_TIME; autostartTimer: NodeJS.Timeout | null = null; state: 'signups' | 'color' | 'play' | 'uno' = 'signups'; currentPlayer: UNOPlayer | null = null; deck: Card[] = Utils.shuffle(createDeck()); discards: Card[] = []; topCard: Card | null = null; awaitUnoPlayer: UNOPlayer | null = null; unoId: ID | null = null; direction: 1 | -1 = 1; suppressMessages: boolean; spectators: { [k: string]: number } = Object.create(null); isPlusFour = false; gameNumber: number; constructor(room: Room, cap: number, suppressMessages: boolean) { super(room); this.gameNumber = room.nextGameNumber(); this.playerCap = cap; this.suppressMessages = suppressMessages || false; this.sendToRoom(`|uhtml|uno-${this.gameNumber}|

A new game of UNO is starting!

${this.suppressMessages ? `

Game messages won't show up unless you're playing or watching.

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

A new game of UNO is starting!

` + `

` + `

` + `${this.suppressMessages ? `

Game messages won't show up unless you're playing or watching.` : ''}

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

A game of UNO is currently in progress.

` + `${this.suppressMessages ? `

Game messages won't show up unless you're playing or watching.` : ''}

` ); } } onStart(isAutostart?: boolean) { if (this.playerCount < 2) { if (isAutostart) { this.room.add("The game of UNO was forcibly ended because there aren't enough users."); this.destroy(); return false; } else { throw new Chat.ErrorMessage("There must be at least 2 players to start a game of UNO."); } } if (this.autostartTimer) clearTimeout(this.autostartTimer); this.sendToRoom(`|uhtmlchange|uno-${this.gameNumber}|

The game of UNO has started.

${this.suppressMessages ? `

Game messages won't show up unless you're playing or watching.` : ''}

`, true); this.state = 'play'; this.onNextPlayer(); // determines the first player // give cards to the players for (const player of this.players) { player.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); } override joinGame(user: User) { if (user.id in this.playerTable) { throw new Chat.ErrorMessage("You have already joined the game of UNO."); } if (this.state === 'signups' && this.addPlayer(user)) { this.sendToRoom(`${user.name} has joined the game of UNO.`); return true; } return false; } override leaveGame(user: User) { if (!(user.id in this.playerTable)) return false; const player = this.playerTable[user.id]; if ((this.state === 'signups' && this.removePlayer(player)) || this.eliminate(user.id)) { this.sendToRoom(`${user.name} has left the game of UNO.`); return true; } return false; } /** * Overwrite the default makePlayer so it makes an UNOPlayer instead. */ makePlayer(user: User) { return new UNOPlayer(user, this); } override onRename(user: User, oldUserid: ID, isJoining: boolean, isForceRenamed: boolean) { 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.renamePlayer(user, oldUserid); } eliminate(userid: ID | undefined) { if (!userid) return null; const player = this.playerTable[userid]; if (!player) return false; const name = player.name; if (this.playerCount === 2) { this.removePlayer(player); this.onWin(this.players[0]); return name; } // handle current player... const removingCurrentPlayer = player === this.currentPlayer; if (removingCurrentPlayer) { 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|${Utils.escapeHTML(name)} has not picked a color, the color will stay as ${this.topCard.changedColor}.`); } } if (this.awaitUnoPlayer === player) this.awaitUnoPlayer = null; if (!this.topCard) { throw new Chat.ErrorMessage(`Unable to disqualify ${name}.`); } // put that player's cards into the discard pile to prevent cards from being permanently lost this.discards.push(...player.hand); if (removingCurrentPlayer) { this.onNextPlayer(); } this.removePlayer(player); if (removingCurrentPlayer) { this.nextTurn(true); } return name; } sendToRoom(msg: string, overrideSuppress = false) { if (!this.suppressMessages || overrideSuppress) { this.room.add(msg).update(); } else { // send to the players first for (const player of this.players) { player.sendRoom(msg); } // send to spectators for (const i in this.spectators) { if (i in this.playerTable) continue; // don't double send to users already in the game. const user = Users.getExact(i); if (user) user.sendTo(this.roomid, msg); } } } getPlayers(showCards?: boolean): string { let playerList = this.players; if (this.direction === -1) playerList = [...playerList].reverse(); if (!showCards) { return playerList.map(p => Utils.escapeHTML(p.name)).join(', '); } let buf = `
    `; for (const player of playerList) { buf += ``; buf += `${Utils.escapeHTML(player.name)} (${player.hand.length})`; buf += ``; } buf += `
`; return buf; } onAwaitUno() { return new Promise(resolve => { if (!this.awaitUnoPlayer) return void 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); }); } nextTurn(starting?: boolean) { void this.onAwaitUno().then(() => { if (!starting) this.onNextPlayer(); if (this.timer) clearTimeout(this.timer); const player = this.currentPlayer!; 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(`|c:|${Math.floor(Date.now() / 1000)}|~|${player.name} has been automatically disqualified.`); this.eliminate(player.id); }, this.maxTime * 1000); }); } onNextPlayer() { this.currentPlayer = this.getNextPlayer(); } getNextPlayer() { // if none is set this.currentPlayer ||= this.players[Math.floor(this.playerCount * Math.random())]; let player = this.players[this.players.indexOf(this.currentPlayer) + this.direction]; // wraparound player ||= (this.direction === 1 ? this.players[0] : this.players[this.playerCount - 1]); return player; } onDraw(player: UNOPlayer) { if (this.currentPlayer !== player || this.state !== 'play') return false; if (player.cardLock) return true; this.onCheckUno(); this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${player.name} has drawn a card.`); const card = this.onDrawCard(player, 1); player.sendDisplay(); player.cardLock = card[0].name; } onPlay(player: UNOPlayer, cardName: string) { if (this.currentPlayer !== player || this.state !== 'play') return false; const 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 cards with the digit ${this.topCard.value}`}.`; } 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.awaitUnoPlayer = player; this.unoId = Math.floor(Math.random() * 100).toString() as ID; } player.sendDisplay(); // update display without the card in it for purposes such as choosing colors this.sendToRoom(`|raw|${Utils.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(); } onRunEffect(value: string, initialize?: boolean) { const colorDisplay = `|uhtml|uno-color|
`; switch (value) { case 'Reverse': this.direction *= -1; this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|The direction of the game has changed.`); // in 2 player games, reverse sends the turn back to the player. if (!initialize && this.playerCount === 2) this.onNextPlayer(); break; case 'Skip': this.onNextPlayer(); this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${this.currentPlayer!.name}'s turn has been skipped.`); break; case '+2': this.onNextPlayer(); this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${this.currentPlayer!.name} has been forced to draw 2 cards.`); this.onDrawCard(this.currentPlayer!, 2); break; case '+4': this.currentPlayer!.sendRoom(colorDisplay); this.state = 'color'; // apply to the next in line, since the current player still has to choose the color const next = this.getNextPlayer(); this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${next.name} has been forced to draw 4 cards.`); this.onDrawCard(next, 4); this.isPlusFour = true; this.timer = setTimeout(() => { this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${this.currentPlayer!.name} has been automatically disqualified.`); this.eliminate(this.currentPlayer!.id); }, this.maxTime * 1000); break; case 'Wild': this.currentPlayer!.sendRoom(colorDisplay); this.state = 'color'; this.timer = setTimeout(() => { this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${this.currentPlayer!.name} has been automatically disqualified.`); this.eliminate(this.currentPlayer!.id); }, this.maxTime * 1000); break; } if (initialize) this.onNextPlayer(); } onSelectColor(player: UNOPlayer, color: Color) { if ( !['Red', 'Blue', 'Green', 'Yellow'].includes(color) || player !== this.currentPlayer || 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(`|c:|${Math.floor(Date.now() / 1000)}|~|The color has been changed to ${color}.`); if (this.timer) clearTimeout(this.timer); // remove color change menu and send the display of their cards again player.sendRoom("|uhtmlchange|uno-color|"); player.sendDisplay(); if (this.isPlusFour) { this.isPlusFour = false; this.onNextPlayer(); // handle the skipping here. } this.nextTurn(); } onDrawCard(player: UNOPlayer, count: number) { if (typeof count === 'string') count = parseInt(count); if (!count || isNaN(count) || count < 1) count = 1; const drawnCards = 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; } drawCard(count: number) { if (typeof count === 'string') count = parseInt(count); if (!count || isNaN(count) || count < 1) count = 1; const drawnCards: Card[] = []; for (let i = 0; i < count; i++) { if (!this.deck.length) { // shuffle the cards back into the deck, or if there are no discards, add another deck into the game. this.deck = this.discards.length ? Utils.shuffle(this.discards) : Utils.shuffle(createDeck()); this.discards = []; // clear discard pile } drawnCards.push(this.deck[this.deck.length - 1]); this.deck.pop(); } return drawnCards; } onUno(player: UNOPlayer, unoId: ID) { // uno id makes spamming /uno uno impossible if (this.unoId !== unoId || player !== this.awaitUnoPlayer) return false; this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|**UNO!** ${player.name} is down to their last card!`); this.awaitUnoPlayer = null; this.unoId = null; } onCheckUno() { if (!this.awaitUnoPlayer) return; // if the previous player hasn't hit UNO before the next player plays something, they are forced to draw 2 cards; if (this.awaitUnoPlayer !== this.currentPlayer) { this.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${this.awaitUnoPlayer.name} forgot to say UNO! and is forced to draw 2 cards.`); this.onDrawCard(this.awaitUnoPlayer, 2); } this.awaitUnoPlayer = null; this.unoId = null; } onSendHand(user: User) { if (this.state === 'signups') return false; this.playerTable[user.id]?.sendDisplay(); } onWin(player: UNOPlayer) { this.sendToRoom( Utils.html`|raw|
Congratulations to ${player.name} for winning the game of UNO!
`, true ); this.destroy(); } override destroy() { if (this.timer) clearTimeout(this.timer); if (this.autostartTimer) clearTimeout(this.autostartTimer); this.sendToRoom(`|uhtmlchange|uno-${this.gameNumber}|
The game of UNO has ended.
`, true); this.setEnded(); for (const player of this.players) player.destroy(); this.room.game = null; } } class UNOPlayer extends Rooms.RoomGamePlayer { hand: Card[]; cardLock: string | null; constructor(user: User, game: UNO) { super(user, game); this.hand = []; this.cardLock = null; } canPlayWildFour() { if (!this.game.topCard) { // should never happen throw new Error(`No top card in the discard pile.`); } const color = (this.game.topCard.changedColor || this.game.topCard.color); if (this.hand.some(c => c.color === color)) return false; return true; } hasCard(cardName: string) { return this.hand.find(card => card.name === cardName); } removeCard(cardName: string) { for (const [i, card] of this.hand.entries()) { if (card.name === cardName) { this.hand.splice(i, 1); break; } } } buildHand() { return Utils.sortBy(this.hand, card => [card.color, card.value]) .map((card, i) => cardHTML(card, i === this.hand.length - 1)); } sendDisplay() { const hand = this.buildHand().join(''); const players = `

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

${this.game.getPlayers(true)}`; const draw = ''; const pass = ''; const uno = ``; if (!this.game.topCard) { // should never happen throw new Error(`No top card in the discard pile.`); } const 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.currentPlayer === this ? `` : ""}` + `
${hand}
${top}
${players}
` + `${this.game.currentPlayer === this ? `
${draw}${pass}
${uno}
` : ""}
` ); } } export const commands: Chat.ChatCommands = { uno: { // roomowner commands off: 'disable', disable(target, room, user) { room = this.requireRoom(); this.checkCan('gamemanagement', null, room); if (room.settings.unoDisabled) { throw new Chat.ErrorMessage("UNO is already disabled in this room."); } room.settings.unoDisabled = true; room.saveSettings(); return this.sendReply("UNO has been disabled for this room."); }, on: 'enable', enable(target, room, user) { room = this.requireRoom(); this.checkCan('gamemanagement', null, room); if (!room.settings.unoDisabled) { throw new Chat.ErrorMessage("UNO is already enabled in this room."); } delete room.settings.unoDisabled; room.saveSettings(); 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) { room = this.requireRoom(); this.checkCan('minigame', null, room); if (room.settings.unoDisabled) throw new Chat.ErrorMessage("UNO is currently disabled for this room."); if (room.game) throw new Chat.ErrorMessage("There is already a game in progress in this room."); const suppressMessages = cmd.includes('private') || !(cmd.includes('public') || room.roomid === 'gamecorner'); let cap = parseInt(target); if (isNaN(cap)) cap = 12; if (cap < 2) cap = 2; room.game = new UNO(room, cap, suppressMessages); this.privateModAction(`A game of UNO was created by ${user.name}.`); this.modlog('UNO CREATE'); }, cap: 'setcap', setcap(target, room, user) { room = this.requireRoom(); this.checkCan('minigame', null, room); const game = this.requireGame(UNO); if (game.state !== 'signups') { throw new Chat.ErrorMessage(`There is no UNO game in the signups phase in this room, so adjusting the player cap would do nothing.`); } let cap = parseInt(target); if (isNaN(cap) || cap < 2) { cap = 2; } game.playerCap = cap; this.privateModAction(`The playercap was set to ${game.playerCap} by ${user.name}.`); this.modlog('UNO PLAYERCAP'); }, start(target, room, user) { room = this.requireRoom(); this.checkCan('minigame', null, room); const game = this.requireGame(UNO); if (game.state !== 'signups') { throw new Chat.ErrorMessage("There is no UNO game in signups phase in this room."); } game.onStart(); this.privateModAction(`The game of UNO was started by ${user.name}.`); this.modlog('UNO START'); }, stop: 'end', end(target, room, user) { room = this.requireRoom(); this.checkCan('minigame', null, room); if (!room.game || room.game.gameid !== 'uno') { throw new Chat.ErrorMessage("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'); }, autodq: 'timer', timer(target, room, user) { room = this.requireRoom(); this.checkCan('minigame', null, room); const game = this.requireGame(UNO); const amount = parseInt(target); if (!amount || amount < 5 || amount > 300) { throw new Chat.ErrorMessage("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.currentPlayer?.id); }, 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) { room = this.requireRoom(); this.checkCan('minigame', null, room); const game = this.requireGame(UNO); if (toID(target) === 'off') { if (!game.autostartTimer) throw new Chat.ErrorMessage("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) { throw new Chat.ErrorMessage("The amount must be a number between 30 and 600 seconds."); } if (game.state !== 'signups') throw new Chat.ErrorMessage("The game of UNO has already started."); if (game.autostartTimer) clearTimeout(game.autostartTimer); game.autostartTimer = setTimeout(() => { game.onStart(true); }, amount * 1000); this.addModAction(`${user.name} has set the UNO autostart timer to ${amount} seconds.`); }, dq: 'disqualify', disqualify(target, room, user) { room = this.requireRoom(); this.checkCan('minigame', null, room); const game = this.requireGame(UNO); const disqualified = game.eliminate(toID(target)); if (disqualified === false) throw new Chat.ErrorMessage(`Unable to disqualify ${target}.`); this.privateModAction(`${user.name} has disqualified ${disqualified} from the UNO game.`); this.modlog('UNO DQ', toID(target)); room.add(`${disqualified} has been disqualified from the UNO game.`).update(); }, // player/user commands j: 'join', join(target, room, user) { const game = this.requireGame(UNO); this.checkChat(); if (!game.joinGame(user)) throw new Chat.ErrorMessage("Unable to join the game."); return this.sendReply("You have joined the game of UNO."); }, l: 'leave', leave(target, room, user) { const game = this.requireGame(UNO); if (!game.leaveGame(user)) throw new Chat.ErrorMessage("Unable to leave the game."); return this.sendReply("You have left the game of UNO."); }, play(target, room, user) { const game = this.requireGame(UNO); if (!game) throw new Chat.ErrorMessage("There is no UNO game going on in this room right now."); const player: UNOPlayer | undefined = game.playerTable[user.id]; if (!player) throw new Chat.ErrorMessage(`You are not in the game of UNO.`); const error = game.onPlay(player, target); if (typeof error === 'string') throw new Chat.ErrorMessage(error); }, draw(target, room, user) { room = this.requireRoom(); const game = room.getGame(UNO); if (!game) throw new Chat.ErrorMessage("There is no UNO game going on in this room right now."); const player: UNOPlayer | undefined = game.playerTable[user.id]; if (!player) throw new Chat.ErrorMessage(`You are not in the game of UNO.`); const error = game.onDraw(player); if (error) throw new Chat.ErrorMessage("You have already drawn a card this turn."); }, pass(target, room, user) { const game = this.requireGame(UNO); if (!game) throw new Chat.ErrorMessage("There is no UNO game going on in this room right now."); const player: UNOPlayer | undefined = game.playerTable[user.id]; if (!player) throw new Chat.ErrorMessage(`You are not in the game of UNO.`); if (game.currentPlayer !== player) throw new Chat.ErrorMessage("It is currently not your turn."); if (!player.cardLock) throw new Chat.ErrorMessage("You cannot pass until you draw a card."); if (game.state === 'color') throw new Chat.ErrorMessage("You cannot pass until you choose a color."); game.sendToRoom(`|c:|${Math.floor(Date.now() / 1000)}|~|${user.name} has passed.`); game.nextTurn(); }, color(target, room, user) { const game = this.requireGame(UNO); const player: UNOPlayer | undefined = game.playerTable[user.id]; if (!player) throw new Chat.ErrorMessage(`You are not in the game of UNO.`); let color: Color; if (target === 'Red' || target === 'Green' || target === 'Blue' || target === 'Yellow' || target === 'Black') { color = target; } else { throw new Chat.ErrorMessage(`"${target}" is not a valid color.`); } game.onSelectColor(player, color); }, uno(target, room, user) { const game = this.requireGame(UNO); const player: UNOPlayer | undefined = game.playerTable[user.id]; if (!player) throw new Chat.ErrorMessage(`You are not in the game of UNO.`); game.onUno(player, toID(target)); }, // information commands '': 'hand', hand(target, room, user) { const game = this.requireGame(UNO); game.onSendHand(user); }, 'c': 'cards', cards(target, room, user) { const game = this.requireGame(UNO); if (!this.runBroadcast()) return false; const players = `Players (${game.playerCount}):

${game.getPlayers(true)}`; this.sendReplyBox(`
${players}
`); }, players: 'getusers', users: 'getusers', getplayers: 'getusers', getusers(target, room, user) { const game = this.requireGame(UNO); if (!this.runBroadcast()) return false; this.sendReplyBox(`Players (${game.playerCount}):${game.getPlayers()}`); }, help(target, room, user) { this.parse('/help uno'); }, // suppression commands suppress(target, room, user) { room = this.requireRoom(); const game = this.requireGame(UNO); this.checkCan('minigame', null, room); target = toID(target); const 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) { throw new Chat.ErrorMessage(`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 = this.requireGame(UNO); if (!game) throw new Chat.ErrorMessage("There is no UNO game going on in this room right now."); if (!game.suppressMessages) throw new Chat.ErrorMessage("The current UNO game is not suppressing messages."); if (user.id in game.spectators) throw new Chat.ErrorMessage("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 = this.requireGame(UNO); if (!game) throw new Chat.ErrorMessage("There is no UNO game going on in this room right now."); if (!game.suppressMessages) throw new Chat.ErrorMessage("The current UNO game is not suppressing messages."); if (!(user.id in game.spectators)) throw new Chat.ErrorMessage("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 12). Use the command [createpublic] to force a public game or [createprivate] to force a private game. Requires: % @ # ~`, `/uno setcap [player cap] - adjusts the player cap of the current UNO 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 cards - displays the number of cards for each player.`, `/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.`, ], }; export const roomSettings: Chat.SettingsHandler = room => ({ label: "UNO", permission: 'editroom', options: [ [`disabled`, room.settings.unoDisabled || 'uno disable'], [`enabled`, !room.settings.unoDisabled || 'uno enable'], ], });