Skip to content
This repository was archived by the owner on Sep 1, 2023. It is now read-only.

Commit 0f77529

Browse files
committed
join mid game option
1 parent 69388f3 commit 0f77529

File tree

7 files changed

+124
-49
lines changed

7 files changed

+124
-49
lines changed

src/constants.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,16 @@ export const defaultColor = 0x6c7086;
7474
export const defaultSettings: UnoGameSettings = {
7575
timeoutDuration: 150,
7676
kickOnTimeout: true,
77-
allowSkipping: true,
77+
allowSkipping: false,
7878
antiSabotage: true,
7979
allowStacking: true,
8080
randomizePlayerList: true,
81-
resendGameMessage: true
81+
resendGameMessage: true,
82+
canRejoin: false
8283
} as const;
8384

85+
export const maxRejoinableTurnCount = 30;
86+
8487
export const autoStartTimeout = 305;
8588

8689
// its "just" 25 days but i still doubt a game will go on for longer than that
@@ -96,6 +99,7 @@ export const ButtonIDs = Object.freeze({
9699
VIEW_CARDS: "view-cards",
97100
PLAY_CARD: "play-game",
98101
LEAVE_GAME: "leave-game",
102+
JOIN_MID_GAME: "join-ongoing",
99103
LEAVE_GAME_CONFIRMATION_YES: "confirm-leave-game",
100104
LEAVE_GAME_CONFIRMATION_NO: "deny-leave-game",
101105
VIEW_GAME_SETTINGS: "view-settings",
@@ -111,12 +115,14 @@ export const SelectIDs = Object.freeze({
111115

112116
export const SettingsIDs = Object.freeze({
113117
TIMEOUT_DURATION: "timeout-duration-setting",
114-
TIMEOUT_DURATION_MODAL: "tiemeout-duration-modal",
115-
TIMEOUT_DURATION_MODAL_SETTING: "timeout-setting-field",
116118
KICK_ON_TIMEOUT: "kick-on-timeout-setting",
117119
ALLOW_SKIPPING: "allow-skipping",
118120
ANTI_SABOTAGE: "anti-sabotage",
119121
ALLOW_CARD_STACKING: "allow-stacking",
120122
RANDOMIZE_PLAYER_LIST: "randomize-list",
121123
RESEND_GAME_MESSAGE: "resend-game-message",
124+
ALLOW_REJOINING: "can-rejoin",
125+
126+
TIMEOUT_DURATION_MODAL: "tiemeout-duration-modal",
127+
TIMEOUT_DURATION_MODAL_SETTING: "timeout-setting-field",
122128
});

src/gameLogic/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { EmbedBuilder } from "@oceanicjs/builders";
22
import { AnyGuildTextChannel, ComponentInteraction, ComponentTypes, Guild, Message, ModalSubmitInteraction, TypedCollection } from "oceanic.js";
33

44
import { client, deleteMessage, sendMessage } from "../client.js";
5-
import { ButtonIDs, cardEmojis, cardEmotes, coloredUniqueCards, defaultColor, defaultSettings, rainbowColors, SelectIDs, SettingsIDs, uniqueVariants, veryLongTime } from "../constants.js";
5+
import { ButtonIDs, cardEmojis, cardEmotes, coloredUniqueCards, defaultColor, defaultSettings, maxRejoinableTurnCount, rainbowColors, SelectIDs, SettingsIDs, uniqueVariants, veryLongTime } from "../constants.js";
66
import database from "../database.js";
77
import { config } from "../index.js";
88
import timeouts from "../timeouts.js";
@@ -25,7 +25,10 @@ export function onTimeout(game: UnoGame<true>, player: string) {
2525
const kickedPlayer = getPlayerMember(game, player);
2626

2727
game.currentPlayer = next(game.players, game.players.indexOf(player));
28-
if (game.settings.kickOnTimeout) game.players.splice(game.players.indexOf(player), 1);
28+
if (game.settings.kickOnTimeout) {
29+
game.players.splice(game.players.indexOf(player), 1);
30+
game.playersWhoLeft.push(player);
31+
}
2932
sendMessage(game.channelID,
3033
`**${kickedPlayer?.nick ?? kickedPlayer?.username}** was ${game.settings.kickOnTimeout ? "removed" : "skipped"} for inactivity`
3134
);
@@ -62,6 +65,7 @@ export function sendGameMessage(game: UnoGame<true>, keepTimeout = false) {
6265
const isUnique = uniqueVariants.includes(game.currentCard);
6366
const currentCardEmote = isUnique ? coloredUniqueCards[`${game.currentCardColor}-${game.currentCard}`] : cardEmotes[game.currentCard];
6467

68+
games[game.channelID] = game;
6569
sendMessage(game.channelID, {
6670
content: `<@${game.currentPlayer}> it's now your turn`,
6771
allowedMentions: { users: true },
@@ -84,7 +88,7 @@ ${game.players.map((p, i) => makeGameLine(game, p, i)).join("\n")}
8488
.setFooter((game._modified ? "This game will not count towards the leaderboard. " : "")
8589
+ `Timeout is ${toHumanReadableTime(game.settings.timeoutDuration).toLowerCase()}`)
8690
.toJSON()],
87-
components: GameButtons()
91+
components: GameButtons(game.settings.canRejoin && game.turn < maxRejoinableTurnCount)
8892
}).then(msg => {
8993
if (!msg) return cancelGameMessageFail(game);
9094

@@ -112,6 +116,7 @@ export function onButtonPress(ctx: ComponentInteraction<ComponentTypes.BUTTON>)
112116
case ButtonIDs.VIEW_CARDS:
113117
case ButtonIDs.PLAY_CARD:
114118
case ButtonIDs.LEAVE_GAME:
119+
case ButtonIDs.JOIN_MID_GAME:
115120
case ButtonIDs.VIEW_GAME_SETTINGS:
116121
if (!game || !hasStarted(game)) return;
117122
onGameButtonPress(ctx, game);

src/gameLogic/notStarted.ts

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,35 @@ import { Card, DebugState, DebugStateType, UnoGame } from "../types.js";
99
import { getPlayerMember, hasStarted, SettingsSelectMenu, shuffle, toTitleCase, updateStats, without } from "../utils.js";
1010
import { games, makeStartMessage, sendGameMessage } from "./index.js";
1111

12+
export function makeDrawCardProxy(startedGame: UnoGame<true>, userId: string, t, p, n) {
13+
t[p] = n;
14+
if (p === "length") startedGame._debug.pushState({
15+
type: "set-cards",
16+
newState: t,
17+
meetsEndCondition: [p === "length" && n === 0, n]
18+
});
19+
if (p === "length" && n === 0) {
20+
// TODO: check that the card shown here is the correct one and dont just pray it is
21+
const card = startedGame.currentCard;
22+
const winner = getPlayerMember(startedGame, userId);
23+
timeouts.delete(startedGame.channelID);
24+
updateStats(startedGame, userId);
25+
delete games[startedGame.channelID];
26+
sendMessage(startedGame.channelID, {
27+
content: `**${winner?.nick ?? winner?.username}** played ${cardEmotes[card]} ${toTitleCase(card)}, and won`,
28+
components: new ComponentBuilder<MessageActionRow>()
29+
.addInteractionButton({
30+
style: ButtonStyles.SUCCESS,
31+
label: "gg",
32+
emoji: ComponentBuilder.emojiToPartial("🏆", "default"),
33+
disabled: true,
34+
customID: "we-have-a-nerd-here🤓"
35+
})
36+
.toJSON()
37+
});
38+
}
39+
}
40+
1241
const drawUntilNotSpecial = (game: UnoGame<true>) => {
1342
let card = game.draw(1).cards[0];
1443
while (uniqueVariants.includes(card)) {
@@ -52,7 +81,7 @@ export function startGame(game: UnoGame<false>, automatic: boolean) {
5281
timeouts.delete(game.channelID);
5382
games[game.channelID].started = true;
5483

55-
const settings = game.settings || { ...defaultSettings };
84+
const settings = { ...defaultSettings, ...game.settings };
5685
const playerList = game.settings.randomizePlayerList ? shuffle(game.players) : game.players;
5786
const players = new Proxy(playerList, {
5887
deleteProperty(t, p) {
@@ -86,12 +115,15 @@ export function startGame(game: UnoGame<false>, automatic: boolean) {
86115
uid: game.uid,
87116
started: true,
88117
message: game.message,
118+
// TODO: proxy to push to playersWhoLeft?
89119
players,
120+
playersWhoLeft: [],
90121
host: game.host,
91122
deck: shuffle(dupe([...cards, ...uniqueVariants])),
92123
drawStackCounter: 0,
93124
currentPlayer: players[0],
94125
lastPlayer: { id: null, duration: 0 },
126+
turn: 0,
95127
settings,
96128
saboteurs: {},
97129
channelID: game.channelID,
@@ -114,32 +146,7 @@ export function startGame(game: UnoGame<false>, automatic: boolean) {
114146
Object.keys(cardsToBeUsed).forEach(id => {
115147
cardsToBeUsed[id] = new Proxy(cardsToBeUsed[id], {
116148
set(t, p, n) {
117-
t[p] = n;
118-
if (p === "length") startedGame._debug.pushState({
119-
type: "set-cards",
120-
newState: t,
121-
meetsEndCondition: [p === "length" && n === 0, n]
122-
});
123-
if (p === "length" && n === 0) {
124-
// TODO: check that the card shown here is the correct one and dont just pray it is
125-
const card = startedGame.currentCard;
126-
const winner = getPlayerMember(startedGame, id);
127-
timeouts.delete(game.channelID);
128-
updateStats(startedGame, id);
129-
delete games[startedGame.channelID];
130-
sendMessage(startedGame.channelID, {
131-
content: `**${winner?.nick ?? winner?.username}** played ${cardEmotes[card]} ${toTitleCase(card)}, and won`,
132-
components: new ComponentBuilder<MessageActionRow>()
133-
.addInteractionButton({
134-
style: ButtonStyles.SUCCESS,
135-
label: "gg",
136-
emoji: ComponentBuilder.emojiToPartial("🏆", "default"),
137-
disabled: true,
138-
customID: "we-have-a-nerd-here🤓"
139-
})
140-
.toJSON()
141-
});
142-
}
149+
makeDrawCardProxy(startedGame, id, t, p, n);
143150
return true;
144151
}
145152
});
@@ -158,6 +165,7 @@ export function startGame(game: UnoGame<false>, automatic: boolean) {
158165
function drawFactory(game: UnoGame<true>): (amount: number) => { cards: Card[], newDeck: Card[] } {
159166
let { deck } = game;
160167
return (amount: number) => {
168+
if (amount > 50) amount = 50;
161169
if (deck.length < amount) deck = deck.concat(shuffle(dupe([...cards, ...uniqueVariants])));
162170
const takenCards = deck.splice(0, amount);
163171
return { cards: takenCards, newDeck: deck };
@@ -214,6 +222,10 @@ export function onSettingsChange(ctx: ComponentInteraction<ComponentTypes.STRING
214222
game.settings.resendGameMessage = !game.settings.resendGameMessage;
215223
break;
216224
}
225+
case SettingsIDs.ALLOW_REJOINING: {
226+
game.settings.canRejoin = !game.settings.canRejoin;
227+
break;
228+
}
217229
default: {
218230
ctx.createFollowup({
219231
content: `The **${ctx.data.values.raw[0]}** setting is missing a handler. this is a bug`

src/gameLogic/playedCards.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ function isSabotage(ctx: ComponentInteraction<ComponentTypes.STRING_SELECT>, gam
1818

1919
if (game.lastPlayer.duration >= maxDuration) {
2020
game.players.splice(game.players.indexOf(ctx.member.id), 1);
21+
game.playersWhoLeft.push(ctx.member.id);
22+
2123
sendMessage(ctx.channel.id, `Removed **${getUsername(game.lastPlayer.id, true, ctx.guild)}** for attempting to sabotage the game`);
2224
if (game.players.length <= 1) {
2325
updateStats(game, game.players[0]);
@@ -144,6 +146,7 @@ export function onCardPlayed(ctx: ComponentInteraction<ComponentTypes.STRING_SEL
144146
if (game.lastPlayer.id === game.currentPlayer || (game.players.length === 2 && wasLastTurnBlocked(game)))
145147
game.lastPlayer.duration++;
146148
else game.lastPlayer = { id: game.currentPlayer, duration: 0 };
149+
game.turn++;
147150

148151
let extraInfo = "";
149152
if (cardPlayed === "draw") {

src/gameLogic/started.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import { config } from "../index.js";
77
import { UnoGame } from "../types.js";
88
import { cardArrayToCount, DrawStackedCardSelect, getUsername, next, PickCardSelect, toTitleCase } from "../utils.js";
99
import { sendGameMessage } from "./index.js";
10+
import { makeDrawCardProxy } from "./notStarted.js";
1011

1112
export function leaveGame(ctx: ComponentInteraction<ComponentTypes.BUTTON>, game: UnoGame<true>) {
1213
if (game.players.includes(ctx.member.id)) {
1314
game.players.splice(game.players.indexOf(ctx.member.id), 1);
15+
game.playersWhoLeft.push(ctx.member.id);
1416
delete game.cards[ctx.member.id];
1517
if (game.currentPlayer === ctx.member.id) game.currentPlayer = next(game.players, game.players.indexOf(game.currentPlayer));
1618

@@ -40,6 +42,7 @@ export function onGameButtonPress(ctx: ComponentInteraction<ComponentTypes.BUTTO
4042
});
4143
break;
4244
}
45+
4346
case ButtonIDs.PLAY_CARD: {
4447
if (!game.players.includes(ctx.member.id)) return ctx.createFollowup({
4548
content: "You aren't in the game!",
@@ -63,6 +66,7 @@ export function onGameButtonPress(ctx: ComponentInteraction<ComponentTypes.BUTTO
6366
});
6467
break;
6568
}
69+
6670
case ButtonIDs.LEAVE_GAME: {
6771
if (!game.players.includes(ctx.member.id)) return;
6872

@@ -83,15 +87,44 @@ export function onGameButtonPress(ctx: ComponentInteraction<ComponentTypes.BUTTO
8387
flags: MessageFlags.EPHEMERAL
8488
});
8589
}
90+
91+
case ButtonIDs.JOIN_MID_GAME: {
92+
if (game.players.includes(ctx.member.id)) return;
93+
if (game.playersWhoLeft.includes(ctx.member.id)) return ctx.createFollowup({
94+
content: "You can't rejoin a game you left!",
95+
flags: MessageFlags.EPHEMERAL
96+
});
97+
98+
game.players.push(ctx.member.id);
99+
// in ascending order
100+
const cardCounts = Object.values(game.cards).map(c => c.length).sort((a, b) => a - b);
101+
// amount of cards is the same as the player with the highest amount of cards
102+
// but at most 5 above the 2nd highest amount of cards
103+
const highest = cardCounts.pop();
104+
const secondHighest = cardCounts.pop();
105+
const { cards, newDeck } = game.draw(highest > secondHighest + 5 ? secondHighest + 5 : highest);
106+
game.cards[ctx.member.id] = new Proxy(cards, {
107+
set(t, p, n) {
108+
makeDrawCardProxy(game, ctx.member.id, t, p, n);
109+
return true;
110+
}
111+
});
112+
game.deck = newDeck;
113+
114+
sendMessage(ctx.channel.id, `**${getUsername(ctx.member.id, true, ctx.guild)}** has joined the game!`);
115+
sendGameMessage(game, true);
116+
break;
117+
}
118+
86119
case ButtonIDs.VIEW_GAME_SETTINGS: {
87120
return ctx.createFollowup({
88121
content: `Kick on timeout: **${game.settings.kickOnTimeout ? "Enabled" : "Disabled"}**
89-
Skipping turns: **${game.settings.allowSkipping ? "Enabled" : "Disabled"}**
90-
Stack +2's and +4's: **${game.settings.allowStacking ? "Enabled" : "Disabled"}**
91-
Randomize order of players: **${game.settings.randomizePlayerList ? "Enabled" : "Disabled"}**
92-
Resend game message: **${game.settings.resendGameMessage ? "Enabled" : "Disabled"}**
93-
Anti sabotage: **find out 🚎**`
94-
.replace(/ {8,}/g, ""),
122+
Skipping turns: **${game.settings.allowSkipping ? "Enabled" : "Disabled"}**
123+
Stack +2's and +4's: **${game.settings.allowStacking ? "Enabled" : "Disabled"}**
124+
Randomize order of players: **${game.settings.randomizePlayerList ? "Enabled" : "Disabled"}**
125+
Resend game message: **${game.settings.resendGameMessage ? "Enabled" : "Disabled"}**
126+
Can join mid game: **${game.settings.resendGameMessage ? "Yes" : "No"}**
127+
Anti sabotage: **find out 🚎**`,
95128
flags: MessageFlags.EPHEMERAL
96129
});
97130
}

src/types.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,20 @@ export interface DebugState {
4242
export type Card = `${typeof colors[number]}-${typeof variants[number]}` | typeof uniqueVariants[number]
4343

4444
export type UnoGameSettings = {
45-
timeoutDuration: number
46-
kickOnTimeout: boolean
45+
timeoutDuration: number,
46+
kickOnTimeout: boolean,
4747
allowSkipping: boolean,
4848
antiSabotage: boolean,
4949
allowStacking: boolean,
5050
randomizePlayerList: boolean,
51-
resendGameMessage: boolean
51+
resendGameMessage: boolean,
52+
canRejoin: boolean
5253
}
5354
export type UnoGame<T extends boolean> = T extends true ? {
5455
uid: string,
5556
started: true,
5657
players: string[],
58+
playersWhoLeft: string[],
5759
host: string,
5860
cards: { [player: string]: Card[] },
5961
currentCard: Card,
@@ -67,6 +69,7 @@ export type UnoGame<T extends boolean> = T extends true ? {
6769
duration: number,
6870
},
6971
settings: UnoGameSettings,
72+
turn: number,
7073
saboteurs: { [id: string]: boolean },
7174
message: Message<AnyGuildTextChannel>,
7275
channelID: string,

src/utils.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,37 @@ import database from "./database.js";
77
import { games, sendGameMessage } from "./gameLogic/index.js";
88
import { Card, PlayerStorage, UnoGame } from "./types.js";
99

10-
export const GameButtons = (() => {
10+
export const GameButtons = ((canRejoin: boolean) => {
1111
const components = new ComponentBuilder<MessageActionRow>()
1212
.addInteractionButton({
1313
style: ButtonStyles.SECONDARY,
1414
customID: ButtonIDs.VIEW_CARDS,
15-
label: "View",
15+
label: "View cards",
1616
emoji: ComponentBuilder.emojiToPartial("🔍", "default")
1717
})
1818
.addInteractionButton({
19-
style: ButtonStyles.PRIMARY,
19+
style: ButtonStyles.SUCCESS,
2020
customID: ButtonIDs.PLAY_CARD,
2121
label: "Play",
2222
emoji: ComponentBuilder.emojiToPartial("🃏", "default")
2323
})
24+
.addRow()
25+
.addInteractionButton({
26+
style: ButtonStyles.SECONDARY,
27+
customID: ButtonIDs.VIEW_GAME_SETTINGS,
28+
emoji: ComponentBuilder.emojiToPartial("⚙", "default")
29+
})
2430
.addInteractionButton({
2531
style: ButtonStyles.DANGER,
2632
customID: ButtonIDs.LEAVE_GAME,
2733
emoji: ComponentBuilder.emojiToPartial("🚪", "default")
2834
})
2935
.addInteractionButton({
30-
style: ButtonStyles.SECONDARY,
31-
customID: ButtonIDs.VIEW_GAME_SETTINGS,
32-
emoji: ComponentBuilder.emojiToPartial("⚙", "default")
36+
style: ButtonStyles.PRIMARY,
37+
customID: ButtonIDs.JOIN_MID_GAME,
38+
label: "Join",
39+
disabled: !canRejoin,
40+
emoji: ComponentBuilder.emojiToPartial("➡️", "default")
3341
});
3442
return components.toJSON();
3543
});
@@ -162,6 +170,11 @@ export const SettingsSelectMenu = (game: UnoGame<false>) => new ComponentBuilder
162170
label: "Resend game message",
163171
value: SettingsIDs.RESEND_GAME_MESSAGE,
164172
description: `if it gets sent too far up because of chat. ${game.settings.resendGameMessage ? "Enabled" : "Disabled"}`
173+
},
174+
{
175+
label: "Allow joining mid game",
176+
value: SettingsIDs.ALLOW_REJOINING,
177+
description: game.settings.canRejoin ? "Enabled" : "Disabled"
165178
}]
166179
})
167180
.toJSON();

0 commit comments

Comments
 (0)