Skip to content

Commit

Permalink
GH-51: Add option to play using Discord buttons
Browse files Browse the repository at this point in the history
  • Loading branch information
utarwyn committed Jan 23, 2022
1 parent 9563f6a commit fab1034
Show file tree
Hide file tree
Showing 11 changed files with 243 additions and 61 deletions.
1 change: 1 addition & 0 deletions config/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"requestCooldownTime": 0,
"simultaneousGames": false,
"gameExpireTime": 30,
"gameBoardButtons": false,
"gameBoardDelete": false,
"gameBoardEmojies": []
}
19 changes: 13 additions & 6 deletions src/__tests__/GameBoardBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,31 @@ describe('GameBoardBuilder', () => {
});

it('should send empty message by default', () => {
expect(builder.toString()).toBe('');
expect(builder.toMessageOptions()).toEqual({ content: '', components: [] });
});

it('should compute title based on entity names', () => {
builder.withTitle({ id: '1', displayName: 'entity1' }, { id: '2', displayName: 'entity2' });
expect(builder.toString()).toBe(':game_die: `entity1` **VS** `entity2`\n\n');
expect(builder.toMessageOptions()).toEqual({
content: ':game_die: `entity1` **VS** `entity2`\n\n',
components: []
});
});

it('should compute board using custom emojies', () => {
builder
.withEmojies(':dog:', ':cat:')
.withBoard(2, [Player.First, Player.Second, Player.Second, Player.First]);
expect(builder.toString()).toBe(':dog: :cat: \n:cat: :dog: \n');

expect(builder.toMessageOptions()).toEqual({
content: ':dog: :cat: \n:cat: :dog: \n',
components: []
});
});

it('should add an empty line between board and state if both defined', () => {
builder.withBoard(1, [Player.None]).withEntityPlaying();
expect(builder.toString()).toContain('\n');
expect(builder.toMessageOptions().content).toContain('\n');
});

it.each`
Expand All @@ -44,7 +51,7 @@ describe('GameBoardBuilder', () => {
${{ toString: () => 'fake' }} | ${'fake, select your move:'}
`('should set state based if playing entity is $entity', ({ entity, state }) => {
builder.withEntityPlaying(entity);
expect(builder.toString()).toBe(state);
expect(builder.toMessageOptions()).toEqual({ content: state, components: [] });
});

it.each`
Expand All @@ -53,6 +60,6 @@ describe('GameBoardBuilder', () => {
${{ toString: () => 'fake' }} | ${':tada: fake has won the game!'}
`('should set state based if winning entity is $entity', ({ entity, state }) => {
builder.withEndingMessage(entity);
expect(builder.toString()).toBe(state);
expect(builder.toMessageOptions()).toEqual({ content: state, components: [] });
});
});
16 changes: 10 additions & 6 deletions src/bot/entity/DuelRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,18 +118,22 @@ export default class DuelRequest {
await this.tunnel.end();
await this.manager.createGame(this.tunnel, this.invited);
} else {
await this.tunnel.end(
localize.__('duel.reject', { invited: formatDiscordName(this.invited.displayName) })
);
await this.tunnel.end({
content: localize.__('duel.reject', {
invited: formatDiscordName(this.invited.displayName)
})
});
}
}

/**
* Called if the challenge has expired without answer.
*/
private async challengeExpired(): Promise<void> {
await this.tunnel.end(
localize.__('duel.expire', { invited: formatDiscordName(this.invited.displayName) })
);
await this.tunnel.end({
content: localize.__('duel.expire', {
invited: formatDiscordName(this.invited.displayName)
})
});
}
}
126 changes: 96 additions & 30 deletions src/bot/entity/GameBoard.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import GameBoardBuilder from '@bot/entity/GameBoardBuilder';
import GameBoardButtonBuilder from '@bot/entity/GameBoardButtonBuilder';
import MessagingTunnel from '@bot/messaging/MessagingTunnel';
import GameStateManager from '@bot/state/GameStateManager';
import GameConfig from '@config/GameConfig';
import localize from '@i18n/localize';
import AI from '@tictactoe/AI';
import Entity from '@tictactoe/Entity';
import Game from '@tictactoe/Game';
import { Collection, Message, MessageOptions, MessageReaction, Snowflake } from 'discord.js';
import {
ButtonInteraction,
Collection,
Message,
MessageOptions,
MessageReaction,
Snowflake
} from 'discord.js';

/**
* Message sent to display the status of a game board.
Expand Down Expand Up @@ -79,7 +87,11 @@ export default class GameBoard {
* Creates or retrieves message of the gameboard.
*/
public get content(): MessageOptions {
const builder = new GameBoardBuilder()
const builder = this.configuration.gameBoardButtons
? new GameBoardButtonBuilder()
: new GameBoardBuilder();

builder
.withTitle(this.entities[0], this.entities[1])
.withBoard(this.game.boardSize, this.game.board)
.withEntityPlaying(
Expand All @@ -95,7 +107,7 @@ export default class GameBoard {
builder.withEmojies(emojies[0], emojies[1]);
}

return { content: builder.toString() };
return builder.toMessageOptions();
}

/**
Expand All @@ -109,19 +121,32 @@ export default class GameBoard {
return GameBoardBuilder.MOVE_REACTIONS.indexOf(reaction);
}

/**
* Converts a button identifier to a move position (from 0 to 8).
* If the move is not valid, returns -1.
*
* @param identifier button identifier
* @private
*/
private static buttonIdentifierToMove(identifier: string): number {
return parseInt(identifier) ?? -1;
}

/**
* Attachs the duel request to a specific message
* and reacts to it in order to get processed.
*
* @param message discord.js message object to attach
*/
public async attachTo(message: Message): Promise<void> {
for (const reaction of GameBoardBuilder.MOVE_REACTIONS) {
try {
await message.react(reaction);
} catch {
await this.onExpire();
return;
if (!this.configuration.gameBoardButtons) {
for (const reaction of GameBoardBuilder.MOVE_REACTIONS) {
try {
await message.react(reaction);
} catch {
await this.onExpire();
return;
}
}
}

Expand Down Expand Up @@ -149,8 +174,12 @@ export default class GameBoard {
/**
* Updates the message.
*/
public async update(): Promise<void> {
return this.tunnel.editReply(this.content);
public async update(interaction?: ButtonInteraction): Promise<void> {
if (interaction) {
await interaction.update(this.content);
} else {
return this.tunnel.editReply(this.content);
}
}

/**
Expand All @@ -169,34 +198,49 @@ export default class GameBoard {
* @param collected collected data from discordjs
* @private
*/
private async onMoveSelected(collected: Collection<Snowflake, MessageReaction>): Promise<void> {
private async onEmojiMoveSelected(
collected: Collection<Snowflake, MessageReaction>
): Promise<void> {
const move = GameBoardBuilder.MOVE_REACTIONS.indexOf(collected.first()!.emoji.name!);
await this.playTurn(move);
}

/**
* Called when a player has selected a valid move button.
*
* @param collected collected data from discordjs
* @private
*/
private async onButtonMoveSelected(interaction: ButtonInteraction): Promise<void> {
const move = GameBoard.buttonIdentifierToMove(interaction.customId);
return await this.playTurn(move, interaction);
}

/**
* Play the current player's turn with a specific move.
*
* @param move move to play for the current player
* @private
*/
private async playTurn(move: number): Promise<void> {
private async playTurn(move: number, interaction?: ButtonInteraction): Promise<void> {
this.game.updateBoard(this.game.currentPlayer, move);

if (this.game.finished) {
const winner = this.getEntity(this.game.winner);

if (this.configuration.gameBoardDelete) {
await this.tunnel.end(new GameBoardBuilder().withEndingMessage(winner).toString());
await this.tunnel.end(
new GameBoardBuilder().withEndingMessage(winner).toMessageOptions()
);
} else {
await this.tunnel.reply?.reactions?.removeAll();
await this.update();
await this.update(interaction);
}

this.manager.endGame(this, winner ?? null);
} else {
this.game.nextPlayer();
await this.update();
await this.update(interaction);
await this.attemptNextTurn();
}
}
Expand All @@ -206,7 +250,7 @@ export default class GameBoard {
* @private
*/
private async onExpire(): Promise<void> {
await this.tunnel.end(localize.__('game.expire'));
await this.tunnel.end({ content: localize.__('game.expire'), components: [] });
this.manager.endGame(this);
}

Expand All @@ -215,19 +259,41 @@ export default class GameBoard {
* @private
*/
private awaitMove(): void {
const expireTime = this.configuration.gameExpireTime ?? 30;
const expireTime = (this.configuration.gameExpireTime ?? 30) * 1000;
if (!this.tunnel.reply || this.tunnel.reply.deleted) return;
this.tunnel.reply
.awaitReactions({
filter: (reaction, user) =>
reaction.emoji.name != null &&
user.id === this.getEntity(this.game.currentPlayer)?.id &&
this.game.isMoveValid(GameBoard.reactionToMove(reaction.emoji.name)),
max: 1,
time: expireTime * 1000,
errors: ['time']
})
.then(this.onMoveSelected.bind(this))
.catch(this.onExpire.bind(this));

const currentEntity = this.getEntity(this.game.currentPlayer)?.id;

if (this.configuration.gameBoardButtons) {
this.tunnel.reply
.createMessageComponentCollector({
filter: interaction =>
interaction.user.id === currentEntity &&
this.game.isMoveValid(
GameBoard.buttonIdentifierToMove(interaction.customId)
),
max: 1,
time: expireTime
})
.on('collect', this.onButtonMoveSelected.bind(this))
.on('end', async (_, reason) => {
if (reason === 'time') {
await this.onExpire();
}
});
} else {
this.tunnel.reply
.awaitReactions({
filter: (reaction, user) =>
reaction.emoji.name != null &&
user.id === currentEntity &&
this.game.isMoveValid(GameBoard.reactionToMove(reaction.emoji.name)),
max: 1,
time: expireTime,
errors: ['time']
})
.then(this.onEmojiMoveSelected.bind(this))
.catch(this.onExpire.bind(this));
}
}
}
33 changes: 18 additions & 15 deletions src/bot/entity/GameBoardBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import localize from '@i18n/localize';
import AI from '@tictactoe/AI';
import Entity from '@tictactoe/Entity';
import { Player } from '@tictactoe/Player';
import { MessageOptions } from 'discord.js';

/**
* Builds string representation of a game board
* whiches will be displayed as a Discord message.
* Builds representation of a game board using text emojis
* whiches will be displayed in a Discord message.
*
* @author Utarwyn
* @since 2.1.0
Expand All @@ -18,29 +19,29 @@ export default class GameBoardBuilder {
public static readonly MOVE_REACTIONS = ['↖️', '⬆️', '↗️', '⬅️', '⏺️', '➡️', '↙️', '⬇️', '↘️'];
/**
* Unicode emojis used for representing the two players.
* @private
* @protected
*/
private emojies = ['⬜', '🇽', '🅾️'];
protected emojies = ['⬜', '🇽', '🅾️'];
/**
* Stores game board title message.
* @private
* @protected
*/
private title: string;
protected title: string;
/**
* Stores game current state.
* @private
* @protected
*/
private state: string;
protected state: string;
/**
* Stores game board size.
* @private
* @protected
*/
private boardSize: number;
protected boardSize: number;
/**
* Stores game board data.
* @private
* @protected
*/
private boardData: Player[];
protected boardData: Player[];

/**
* Constructs a new game board builder.
Expand Down Expand Up @@ -127,9 +128,11 @@ export default class GameBoardBuilder {
}

/**
* Constructs final string representation of the game board.
* Constructs final representation of the game board.
*
* @returns message options of the gameboard
*/
toString(): string {
toMessageOptions(): MessageOptions {
// Generate string representation of the board
let board = '';

Expand All @@ -142,6 +145,6 @@ export default class GameBoardBuilder {

// Generate final string
const state = this.state && board ? '\n' + this.state : this.state;
return this.title + board + state;
return { content: this.title + board + state, components: [] };
}
}

0 comments on commit fab1034

Please sign in to comment.