рдлрд┐рд░ рд╕реЗ рд╣реИрд▓реЛ! рдХреНрдпрд╛ рдЖрдк рдЕрдХреНрд╕рд░ рдЙрди рдкрд░рд┐рдпреЛрдЬрдирд╛рдУрдВ рдХреЗ рд▓рд┐рдП рд╡рд┐рдЪрд╛рд░реЛрдВ рдХреЗ рд╕рд╛рде рдЖрддреЗ рд╣реИрдВ рдЬреЛ рд╕рдЪрдореБрдЪ рдЖрдкрдХреЛ рд╕реЛрдиреЗ рд╕реЗ рд░реЛрдХрддреЗ рд╣реИрдВ? рдпрд╣ рдорд╣рд╕реВрд╕ рдХрд░рддреЗ рд╣реБрдП рдХрд┐ рдЖрдк рдЪрд┐рдВрддрд╛ рдХрд░рддреЗ рд╣реИрдВ, рдЪрд┐рдВрддрд╛ рдХрд░рддреЗ рд╣реИрдВ рдФрд░ рд╕рд╛рдорд╛рдиреНрдп рд░реВрдк рд╕реЗ рдЕрдиреНрдп рдЪреАрдЬреЛрдВ рдкрд░ рдХрд╛рдо рдирд╣реАрдВ рдХрд░ рд╕рдХрддреЗред рдпрд╣ рдореЗрд░реЗ рд╕рд╛рде рд╕рд╛рд▓ рдореЗрдВ рдХрдИ рдмрд╛рд░ рд╣реЛрддрд╛ рд╣реИред рдХреБрдЫ рд╡рд┐рдЪрд╛рд░ рдЕрдкрдиреЗ рдЖрдк рдореЗрдВ рдЗрд╕ рд╡рд┐рд╖рдп рдХреЛ рд╕рдордЭрдиреЗ рдФрд░ рд╕рдордЭрдиреЗ рдХреЗ рдмрд╛рдж рдЧрд╛рдпрдм рд╣реЛ рдЬрд╛рддреЗ рд╣реИрдВ рдХрд┐ рдЗрд╕ рддрд░рд╣ рдХреА рдкрд╣рд▓ рд╕реЗ рд▓рд╛рдн рдкреНрд░рд╛рдкреНрдд рдХрд░рдирд╛ рдмрд╣реБрдд рдХрдард┐рди рд╣реЛрдЧрд╛ред рд▓реЗрдХрд┐рди рдЗрд╕ рддрд░рд╣ рдХреЗ рд╡рд┐рдЪрд╛рд░ рд╣реИрдВ, рдЬреЛ рдХреБрдЫ рдШрдВрдЯреЗ рднреА рд╡рд┐рдХрд╕рд┐рдд рдХрд░ рд░рд╣реЗ рд╣реИрдВ, рдореБрдЭреЗ рдЗрддрдирд╛ рдкрдХрдбрд╝ рд▓реЗрддреЗ рд╣реИрдВ рдХрд┐ рдореИрдВ рдкрд╣рд▓реЗ рд╕реЗ рд╣реА рдирд╣реАрдВ рдЦрд╛ рд╕рдХрддрд╛ рд╣реВрдВред рдпрд╣ рдкреЛрд╕реНрдЯ рдЗрд╕ рдмрд╛рдд рдХреЗ рдмрд╛рд░реЗ рдореЗрдВ рд╣реИ рдХрд┐ рдХреИрд╕реЗ рдореИрдВрдиреЗ рдХрд╛рдо рдХреЗ рдмрд╛рдж рдПрдХ рд╢рд╛рдо рдореЗрдВ рдЗрди рд╡рд┐рдЪрд╛рд░реЛрдВ рдореЗрдВ рд╕реЗ рдПрдХ рдХреЛ рдорд╣рд╕реВрд╕ рдХрд┐рдпрд╛ рдФрд░ рднреБрдЦрдорд░реА рд╕реЗ рдирд╣реАрдВ рдорд░рд╛ред рд▓реЗрдХрд┐рди рдпрд╣ рд╡рд┐рдЪрд╛рд░ рдЦреБрдж рд╢реБрд░реВ рдореЗрдВ рдХрд╛рдлреА рдорд╣рддреНрд╡рд╛рдХрд╛рдВрдХреНрд╖реА рд▓рдЧ рд░рд╣рд╛ рдерд╛ - рдПрдХ PvP рдЦреЗрд▓ рдЬрд┐рд╕рдореЗрдВ рдЦрд┐рд▓рд╛рдбрд╝реА рдПрдХ-рджреВрд╕рд░реЗ рдХреЗ рд╕рд╛рде рдкреНрд░рддрд┐рд╕реНрдкрд░реНрдзрд╛ рдХрд░рддреЗ рд╣реИрдВ, рд╕рд╡рд╛рд▓реЛрдВ рдХреЗ рдЬрд╡рд╛рдм рджреЗрддреЗ рд╣реИрдВред

" - 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 . - , , - .
рдореИрдВ рд╡рд╛рд╕реНрддрд╡ рдореЗрдВ рдЖрд╢рд╛ рдХрд░рддрд╛ рд╣реВрдВ рдХрд┐ рдореЗрд░реА рдкреЛрд╕реНрдЯ рдХрд┐рд╕реА рдХреЛ рдЕрдкрдиреЗ рд╡рд┐рдЪрд╛рд░реЛрдВ рдХреЛ рд▓рд╛рдЧреВ рдХрд░рдиреЗ рдХреЗ рд▓рд┐рдП рдкреНрд░реЗрд░рд┐рдд рдХрд░реЗрдЧреА, рдкрд╛рд▓рддреВ рдкрд░рд┐рдпреЛрдЬрдирд╛рдПрдВ рддреЗрдЬреА рд╕реЗ рдХрд░реЗрдВ рдФрд░ рдЙрдиреНрд╣реЗрдВ рд░рд┐рд▓реАрдЬ рдХрд░рдиреЗ рдХреЗ рд▓рд┐рдП рд▓рд╛рдПрдВ, рд╡рд┐рдЪрд╛рд░ рд╕реЗ рдХрд╛рд░реНрдпрд╛рдиреНрд╡рдпрди рддрдХ рдХреА рдкрд░рд┐рдпреЛрдЬрдирд╛рдУрдВ рдХреА рдкреВрд░реА рдкреНрд░рдХреНрд░рд┐рдпрд╛ рдореЗрдВ рдорджрдж рдХрд░реЗрдВ, рдпрд╛ рдмрд╕ рдЖрдкрдХреЛ рдХрдо рд╕реЗ рдХрдо рдкрдврд╝рдиреЗ рдХрд╛ рдЖрдирдВрдж рдкреНрд░рд╛рдкреНрдд рдХрд░рдиреЗ рджреЗрдВред рдореБрдЭреЗ рд▓рдЧрднрдЧ рдпрдХреАрди рд╣реИ рдХрд┐ рдмреЙрдЯ рдмрдбрд╝реА рд╕рдВрдЦреНрдпрд╛ рдореЗрдВ рдЦрд┐рд▓рд╛рдбрд╝рд┐рдпреЛрдВ рдХрд╛ рд╕рд╛рдордирд╛ рдирд╣реАрдВ рдХрд░реЗрдЧрд╛ред рдлрд┐рд░ рднреА, рдЯрд╛рдЗрдорд░, рдореБрдЦреНрдп рдореЗрдВ рдЧреНрд░рд╛рд╣рдХ рд╕реАрдзреЗ, рдПрдХ рдкреНрд░рдХреНрд░рд┐рдпрд╛, рдЖрджрд┐, рдЖрджрд┐ред рдлрд┐рд░ рднреА, рдпрджрд┐ рдХреЛрдИ рдкрд░рд┐рдгрд╛рдо рджреЗрдЦрдиреЗ рдореЗрдВ рд░реБрдЪрд┐ рд░рдЦрддрд╛ рд╣реИ, рддреЛ @QuizMatchGameBot рдмреЙрдЯ рджреЗрдЦреЗрдВ ред рд╕рднреА рдХреЗ рд▓рд┐рдП рд╢рд╛рдВрддрд┐!