Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

GH-51: Play using Discord buttons #158

Merged
merged 5 commits into from
Jan 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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();
}
});
}
}
}