Skip to content

Commit

Permalink
Merge pull request #158 from utarwyn/issue-51-using-buttons
Browse files Browse the repository at this point in the history
GH-51: Play using Discord buttons
  • Loading branch information
utarwyn committed Jan 24, 2022
2 parents 49e08d1 + 5491e05 commit 70cfac9
Show file tree
Hide file tree
Showing 12 changed files with 307 additions and 62 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,
"gameBoardReactions": 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: [] });
});
});
59 changes: 59 additions & 0 deletions src/__tests__/GameBoardButtonBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import GameBoardButtonBuilder from '@bot/entity/GameBoardButtonBuilder';
import localize from '@i18n/localize';
import AI from '@tictactoe/AI';
import { Player } from '@tictactoe/Player';
import { MessageButton } from 'discord.js';

jest.mock('@tictactoe/AI');

describe('GameBoardButtonBuilder', () => {
let builder: GameBoardButtonBuilder;

beforeAll(() => {
localize.loadFromLocale('en');
});

beforeEach(() => {
builder = new GameBoardButtonBuilder();
});

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

it('should compute board components', () => {
const options = builder
.withBoard(2, [Player.First, Player.None, Player.None, Player.Second])
.toMessageOptions();

expect(options.components).toHaveLength(2);
expect(options.components![0].components).toHaveLength(2);
expect(options.components![1].components).toHaveLength(2);
expect((options.components![0].components[0] as MessageButton).label).toBe('X');
expect((options.components![0].components[1] as MessageButton).label).toBe(' ');
expect((options.components![1].components[0] as MessageButton).label).toBe(' ');
expect((options.components![1].components[1] as MessageButton).label).toBe('O');
});

it('should compute board using custom emojies', () => {
const options = builder
.withEmojies(':dog:', ':cat:')
.withBoard(2, [Player.First, Player.Second, Player.Second, Player.First])
.toMessageOptions();

expect((options.components![0].components[0] as MessageButton).emoji?.name).toBe('dog');
expect((options.components![0].components[1] as MessageButton).emoji?.name).toBe('cat');
expect((options.components![1].components[0] as MessageButton).emoji?.name).toBe('cat');
expect((options.components![1].components[1] as MessageButton).emoji?.name).toBe('dog');
});

it.each`
entity | state
${undefined} | ${''}
${new AI()} | ${':robot: AI is playing, please wait...'}
${{ toString: () => 'fake' }} | ${'fake, select your move:'}
`('should set state based if playing entity is $entity', ({ entity, state }) => {
builder.withEntityPlaying(entity);
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)
})
});
}
}
132 changes: 101 additions & 31 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.gameBoardReactions
? new GameBoardBuilder()
: new GameBoardButtonBuilder();

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,33 @@ 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;
// Add reactions below message if enabled
if (this.configuration.gameBoardReactions) {
for (const reaction of GameBoardBuilder.MOVE_REACTIONS) {
try {
await message.react(reaction);
} catch {
await this.onExpire();
return;
}
}
}

Expand All @@ -148,9 +174,15 @@ export default class GameBoard {

/**
* Updates the message.
*
* @param interaction interaction to update if action was triggered by it
*/
public async update(): Promise<void> {
return this.tunnel.editReply(this.content);
public async update(interaction?: ButtonInteraction): Promise<void> {
if (interaction) {
return interaction.update(this.content);
} else {
return this.tunnel.editReply(this.content);
}
}

/**
Expand All @@ -169,34 +201,50 @@ 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);
return 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 this.playTurn(move, interaction);
}

/**
* Play the current player's turn with a specific move.
*
* @param move move to play for the current player
* @param interaction interaction to update if action was triggered by it
* @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 +254,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 +263,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.gameBoardReactions) {
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));
} else {
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();
}
});
}
}
}

0 comments on commit 70cfac9

Please sign in to comment.