Rebonjour! Trouvez-vous souvent des idĂ©es de projets qui vous ont littĂ©ralement empĂȘchĂ© de dormir? Ce sentiment lorsque vous vous inquiĂ©tez, vous inquiĂ©tez et ne pouvez pas travailler normalement sur d'autres choses. Cela m'arrive plusieurs fois par an. Certaines idĂ©es disparaissent d'elles-mĂȘmes aprĂšs avoir approfondi le sujet et compris qu'il sera extrĂȘmement difficile de bĂ©nĂ©ficier d'une telle initiative. Mais il y a de telles idĂ©es, en train de se dĂ©velopper qui, mĂȘme quelques heures, me capturent tellement que je ne peux pas dĂ©jĂ manger. Ce post raconte comment j'ai rĂ©ussi Ă rĂ©aliser l'une de ces idĂ©es en quelques soirĂ©es aprĂšs le travail et que je ne suis pas mort de faim. Mais l'idĂ©e elle-mĂȘme semblait initialement assez ambitieuse - un jeu PvP dans lequel les joueurs se font concurrence, rĂ©pondant Ă des questions.

" - Botlify", . - , . , 14-15 , .
, 2013 . , , , "100 1". , , , . , , -. Telegram API, -.
, : -, , , ( , 1% â YAGNI), , , . , , . . 3 . â , - . , : " , , ". , . , , . â . 10 , , , .
, , , Telegram.
1.
, . Telegram . . :
3 . . , . 6 , 3 .
- â 8
- â 5
- â 3
- â 2
- â 1
- â 1
- â 0
, , . â . , 1 "". â "". â . , , 20 . ? , 0 . 3 .
, , , " ", (, , ). , , ,

2.
, . , , , "":
- (Player) â id , , , , , .
- (Game) â , , , , ..
- (Round) â . , , .
- (Question) â , - .
- (Answer) â , -
. , , , , - , , , , (, ?), â .
, : " , ?". , :
- , : , , , . :
/start . , , â . N- , . â , " ". .
!
, . , â , . NodeJS c TypeScript . ? . 10 :
interface Player {
id: string;
rating: number;
username: string;
games: number;
wins: number;
losses: number;
createdAt: number;
lastGame: number|null;
}
, , - . , , "" MySQL, Postgres MongoDB . Redis. , , key-value JSON. , Redis . , Redis "" , , ""(scores), pub/sub, Redis , .
, , , winGame looseGame, . , , Redis sorted set, , (score). . sorted set id , (score) .
, , . , , . ? "Rating system". - , ELO Rating. , , , . npmjs.com elo-rating ! https://www.npmjs.com/package/elo-rating. , . , â , . , , ! "" player.service.ts , , , "" "" 130 . , , â , , , Player - .
interface IPlayerService {
createPlayer(id: string, name?: string): Promise<boolean>;
getPlayerName(id: string): Promise<string>;
countPlayers(): Promise<number>;
getRatingPosition(id: string): Promise<number>;
getTopPlayerIds(): Promise<string[]>;
getTopPlayerScores(): Promise<{id: string, value: number}[]>;
listPlayers(): Promise<Player[]>;
getPlayerRating(id: string): Promise<number>;
getPlayerScores(id: string): Promise<number>;
getPlayerScorePosition(id: string): Promise<number>;
getPlayerGames(id: string): Promise<number>;
getPlayerWins(id: string): Promise<number>;
winGame(id: string, newRating: number): Promise<boolean>;
looseGame(id: string, newRating: number): Promise<boolean>;
setSession(id: string, data: any): Promise<void>;
getSession(id: string): Promise<any>;
}
, , "" , . .
, â . . , , , :
. -, . - , ( ).
interface Question {
id: string;
message: string;
createdAt: number;
}
key-value, â ID, JSON- . (set) ID , . ? :
, :
class QuestionService {
public static getQuestionKey(id: string) {
return `questions:${id}`;
}
public static getQuestionsListKey() {
return 'questions';
}
async getQuestion(id: string) {
return JSON.parse(
await this.redisService.getClient().get(QuestionService.getQuestionKey(id))
);
}
async addQuestion(message: string): Promise<string> {
const data = {
id: randomStringGenerator(),
createdAt: Date.now(),
message,
};
await this.redisService.getClient()
.set(QuestionService.getQuestionKey(data.id), JSON.stringify(data));
await this.redisService.getClient()
.sadd(QuestionService.getQuestionsListKey(), data.id);
return data.id;
}
async getRandomQuestionId(): Promise<string> {
return this.redisService.getClient()
.srandmember(QuestionService.getQuestionsListKey());
}
async getRandomQuestion() {
const id = await this.getRandomQuestionId(gameId);
return this.getQuestion(id);
}
}
, Redis. , , . , â .
-, . -, , , . , , . , , , , , - , . - , , . , - . 5 https://github.com/aceakash/string-similarity . , â . , , 0.75 , . â , .
(sorted set) . QuestionService, , , -( ), , , ..
, . , - . , QuestionService :
private turnScores: number[] = [8, 5, 3, 2, 1, 1];
public static getAnswersKey(questionId: string): string {
return `answers:${questionId}`;
}
async addAnswer(questionId: string, answer: string): Promise<string> {
return this.redisService.getClient()
.zincrby(QuestionService.getAnswersKey(questionId), 1, answer);
}
async getQuestionAnswers(questionId: string): Promise<string[]> {
return this.redisService.getClient()
.zrevrange(QuestionService.getAnswersKey(questionId), 0, -1);
}
async getTopAnswers(gameId: string, questionId: string): Promise<string[]> {
const copiedAnswers = await this.redisService.getClient()
.get(`answers:game:${gameId}:q:${questionId}`);
if (!copiedAnswers) {
const ans = await this.redisService.getClient()
.zrevrange(QuestionService.getAnswersKey(questionId), 0, 5);
await this.redisService.getClient()
.set(`answers:game:${gameId}:q:${questionId}`, JSON.stringify(ans));
return ans;
}
return JSON.parse(copiedAnswers);
}
async existingAnswer(questionId: string, answer: string): Promise<string | null> {
const answers = await this.getQuestionAnswers(questionId);
const matches = stringSimilarity.findBestMatch(answer, answers);
return matches.bestMatch.rating >= 0.75
?
matches.bestMatch.target
:
null;
}
async getExistingAnswerScore(
gameId: string, questionId: string, answer: string
): Promise<number> {
const topAnswers = await this.getTopAnswers(gameId, questionId);
const matches = stringSimilarity.findBestMatch(answer, topAnswers);
return matches.bestMatch.rating >= 0.75
?
this.turnScores[matches.bestMatchIndex]
:
0;
}
async submitAnswer(gameId: string, questionId: string, answer: string): Promise<number> {
answer = answer.toLowerCase();
const existingAnswer = await this.existingAnswer(questionId, answer);
if (!existingAnswer) {
await this.addAnswer(questionId, answer);
return 0;
} else {
await this.addAnswer(questionId, existingAnswer);
return this.getExistingAnswerScore(gameId, questionId, existingAnswer);
}
}
, : . , , â 0 . , "" , , . , , . , , â„ 0.75 . "" , - . getTopAnswers? , â ;) , < 300 , ( - gameId, ).
, ? , . , ?
, () . 2 â player1 player2 . : , , . , , " " , . â .
enum GameStatus {
WaitingForPlayers,
Active,
Finished,
}
key-value, = ID, value = JSON.stringify(data). ,
interface Game {
id: string;
player1: string;
player2: string;
joined: string[];
status: GameStatus;
round?: number;
winner?: string;
createdAt: number;
updatedAt?: number;
}
, , (? 1 â 1 ), .
interface GameRound {
index: number;
question: string;
currentPlayer: string;
turn: number;
answers: UserAnswer[];
updatedAt?: number;
}
game.service.ts , : , , , , . , - , , . Redis pub/sub. , . , â , - , , .
GameServiceclass GameService {
public static CHANNEL_G_CREATED = 'game-created';
public static CHANNEL_G_STARTED = 'game-started';
public static CHANNEL_G_ENDED = 'game-ended';
public static CHANNEL_G_NEXT_ROUND = 'game-next-round';
public static CHANNEL_G_NEXT_TURN = 'game-next-turn';
public static CHANNEL_G_ANSWER_SUBMIT = 'game-next-turn';
private defaultRounds: number;
private defaultTurns: number;
private timers: any = {};
constructor(
private readonly redisService: RedisService,
private readonly playerService: PlayerService,
private readonly questionService: QuestionService,
@Inject('EventPublisher') private readonly eventPublisher,
) {
this.defaultRounds = config.game.defaultRounds;
this.defaultTurns = config.game.defaultTurns;
}
public static getGameKey(id: string) {
return `game:${id}`;
}
async getGame(gameId: string): Promise<Game> {
const game = await this.redisService.getClient()
.get(GameService.getGameKey(gameId));
if (!game) {
throw new Error(`Game ${gameId} not found`);
}
return JSON.parse(game);
}
async saveGame(game: Game) {
return this.redisService.getClient().set(
GameService.getGameKey(game.id),
JSON.stringify(game)
);
}
async saveRound(gameId: string, round: GameRound) {
return this.redisService.getClient().set(
GameService.getRoundKey(gameId, round.index),
JSON.stringify(round)
);
}
async initGame(player1: string, player2: string): Promise<Game> {
const game: Game = {
id: randomStringGenerator(),
player1,
player2,
joined: [],
status: GameStatus.WaitingForPlayers,
createdAt: Date.now(),
};
await this.saveGame(game);
this.eventPublisher.emit(
GameService.CHANNEL_G_CREATED, JSON.stringify(game)
);
return game;
}
async joinGame(playerId: string, gameId: string) {
const game: Game = await this.getGame(gameId);
if (!game) throw new Error('Game not found err')
if (isUndefined(game.joined.find(element => element === playerId))) {
game.joined.push(playerId);
game.updatedAt = Date.now();
await this.saveGame(game);
}
if (game.joined.length === 2) return this.startGame(game);
}
async startGame(game: Game) {
game.round = 0;
game.updatedAt = Date.now();
game.status = GameStatus.Active;
this.eventPublisher.emit(
GameService.CHANNEL_G_STARTED, JSON.stringify(game)
);
await this.questionService.pickQuestionsForGame(game.id);
await this.nextRound(game);
}
async nextRound(game: Game) {
clearTimeout(this.timers[game.id]);
if (game.round >= this.defaultRounds) {
return this.endGame(game);
}
game.round++;
const round: GameRound = {
index: game.round,
question: await this.questionService.getRandomQuestionId(game.id),
currentPlayer: game.player1,
turn: 1,
answers: [],
};
await this.saveGame(game);
await this.saveRound(game.id, round);
this.timers[game.id] = setTimeout(async () => {
await this.submitAnswer(round.currentPlayer, game.id, '');
}, 20000);
await this.eventPublisher.emit(
GameService.CHANNEL_G_NEXT_ROUND,
JSON.stringify({game, round})
);
}
async nextTurn(game: Game, round: GameRound) {
clearTimeout(this.timers[game.id]);
if (round.turn >= this.defaultTurns) {
return this.nextRound(game);
}
round.turn++;
round.currentPlayer = this.anotherPlayer(round.currentPlayer, game);
round.updatedAt = Date.now();
await this.eventPublisher.emit(
GameService.CHANNEL_G_NEXT_TURN,
JSON.stringify({game, round})
);
this.timers[game.id] = setTimeout(async () => {
await this.submitAnswer(round.currentPlayer, game.id, '');
}, 20000);
return this.saveRound(game.id, round);
}
async answerExistInRound(round: GameRound, answer: string) {
const existingKey = round.answers.find(
rAnswer => stringSimilarity.compareTwoStrings(answer, rAnswer.value) >= 0.85
);
return !isUndefined(existingKey);
}
async submitAnswer(playerId: string, gameId: string, answer: string) {
const game = await this.getGame(gameId);
const round = await this.getCurrentRound(gameId);
if (playerId !== round.currentPlayer) {
throw new Error('Its not your turn');
}
if (answer.length === 0) {
round.updatedAt = Date.now();
this.eventPublisher.emit(
GameService.CHANNEL_G_ANSWER_SUBMIT,
JSON.stringify({game, answer, score: 0, playerId})
);
return this.nextTurn(game, round);
}
if (await this.answerExistInRound(round, answer)) {
throw new Error(' ');
}
round.answers.push({ value: answer, playerId, turn: round.turn });
const score = await this.questionService.submitAnswer(
gameId, round.question, answer
);
if (score > 0) {
await this.addGameScore(gameId, playerId, score);
}
round.updatedAt = Date.now();
this.eventPublisher.emit(
GameService.CHANNEL_G_ANSWER_SUBMIT,
JSON.stringify({game, answer, score, playerId})
);
return this.nextTurn(game, round);
}
async addGameScore(gameId: string, playerId: string, score: number) {
await this.redisService.getClient()
.zincrby(`game:${gameId}:player:scores`, score, playerId);
}
async endGame(game: Game) {
clearTimeout(this.timers[game.id]);
game.updatedAt = Date.now();
game.status = GameStatus.Finished;
game.updatedAt = Date.now();
const places = await this.redisService.getClient()
.zrevrange(`game:${game.id}:player:scores`, 0, -1);
game.winner = places[0];
const winnerRating: number = await this.playerService.getPlayerRating(game.winner);
const looserRating: number = await this.playerService.getPlayerRating(places[1]);
const newRatings = rating.calculate(winnerRating, looserRating);
await this.playerService.winGame(game.winner, newRatings.playerRating);
await this.playerService.looseGame(places[1], newRatings.opponentRating);
await this.redisService.getClient().expire(GameService.getGameKey(game.id), 600)
this.eventPublisher.emit(GameService.CHANNEL_G_ENDED, JSON.stringify(game));
return game;
}
}
, -. , . , â . , , , . Redis (sorted sets). ( , ). . . game.service.ts,
async addPlayerToMatchmakingList(id: string) {
const playerRating = await this.playerService.getPlayerRating(id);
return this.redisService.getClient().zadd('matchmaking-1-1', playerRating, id);
}
async removePlayerFromMatchmakingList(id: string) {
return this.redisService.getClient().zrem('matchmaking-1-1', id);
}
async getMatchmakingListData() {
return this.redisService.getClient().zrange('matchmaking-1-1', 0, -1);
}
async matchPlayers(): Promise<Game> {
const players = await this.redisService.getClient()
.zrevrange('matchmaking-1-1', 0, -1);
while (players.length > 0) {
const pair = players.splice(0, 2);
if (pair.length === 2) {
return this.coinflip()
?
this.initGame(pair[0], pair[1])
:
this.initGame(pair[1], pair[0]);
}
}
}
, , . , . , - .
, . , .
- â . , - , css JS- â Telegram. . , API . , , .
, , - , .
"100 1", AndreyDegtyaruk!
, , ! , . /help
, , :
"100 1" . ! ,
3 . . , . 6 , 3
- â 8
- â 5
- â 3
- â 2 .
- â 1 .
- â 1 .
- â 0
! ! 20 . ? 0 . 3 ,
, , - , , ( , ). 2 : pull push. Pull , "" Telegram , API Telegram getUpdates. Push , , â webhook. , "" , . , !
SDK Telegraf, , â , . , .
, "" , API key , , . â bot.service.ts, Telegram. , , . :
- /start â (- inline, , custom keyboard )
- /help â
- /player_info â (, , )
- /global_rating â
- /go â
, . , -
getMainMenuKeyboard() {
return {
inline_keyboard: [
[
{
text: ' ',
callback_data: JSON.stringify({type: 'go'})
}
],
[
{
text: ' ',
callback_data: JSON.stringify({type: 'player-rating'})
}
],
[
{
text: ' ',
callback_data: JSON.stringify({type: 'rules'})
}
],
[
{
text: ' ',
callback_data: JSON.stringify({type: 'stats'})
}
],
],
};
}
callback_data. Telegram , , .
, switch/case statement type, . start help, , . , Telegram https, - . , Ngrok .
async commandStart(ctx) {
let name = `${escapeHtml(ctx.from.first_name)}`;
name += ctx.from.last_name ? escapeHtml(ctx.from.last_name) : '';
await this.playerService.createPlayer(ctx.from.id.toString(10), name);
await this.bot.telegram.sendMessage(ctx.from.id, messages.start(name), {
parse_mode: 'HTML',
reply_markup: this.getMainMenuKeyboard(),
});
}
, . , . , , . , , . , .
, Redis pub/sub. . , , , ( ). main.ts, , , . . ""
const redisClient = app.get('EventSubscriber');
redisClient.on(GameService.CHANNEL_G_CREATED, async (data) => {
try {
await botService.sendGameCreated(JSON.parse(data));
} catch (error) {
console.log('Error in CHANNEL_G_CREATED');
console.log(error);
}
});
BotService.sendGameCreated, , .
( , ), - , , , . , ? Stages Scenes, Telegraf, , . , , 30 , Redis.
, , . , , , â ( ). â , , .
this.bot.on('text', async (ctx: any) => {
ctx.message.text = escapeHtml(ctx.message.text);
ctx.message.text = ctx.message.text.toLowerCase();
try {
const state = await this.playerService.getSession(ctx.from.id.toString());
if (!state.currentGame) {
return ctx.reply(
' , .
/help, '
);
}
await this.gameIncomingMessage(ctx, state);
} catch (error) {
console.log('Error during processing TEXT message');
console.log(error);
}
});
this.bot.on('message', async ctx => {
ctx.reply(' ');
});
, , .
Telegram- , . , - . , </>, parseMode HTML, : " html" . , . localhost 50 . , NodeJS . - , , - .
J'espĂšre vraiment que mon message inspirera quelqu'un Ă mettre en Ćuvre ses idĂ©es, Ă accĂ©lĂ©rer les projets pour les animaux de compagnie et Ă les amener Ă la publication, Ă aider l'ensemble du processus de crĂ©ation de projets de l'idĂ©e Ă la mise en Ćuvre, ou tout simplement Ă vous laisser au moins un peu de plaisir Ă lire. Je suis presque sĂ»r que le bot ne rĂ©sistera pas Ă un grand nombre de joueurs. Pourtant, minuteries, abonnĂ© directement dans le principal, un processus, etc., etc. NĂ©anmoins, si quelqu'un souhaite regarder le rĂ©sultat, recherchez le bot @QuizMatchGameBot . Paix pour tous!