再一次问好!您是否经常想出一些实际上使您无法入睡的项目创意?当您担心,担心并且无法正常处理其他事情时的那种感觉。一年几次发生在我身上。在深入研究该主题并理解从这样的倡议中受益将非常困难之后,一些想法会自行消失。但是有这样的想法,即使经过几个小时,这些想法就吸引了我,以至于我已经无法进食。这篇文章是关于我在下班后的几个晚上如何实现这些想法中的一个,并且没有死于饥饿。但是这个想法最初听起来很雄心勃勃-一种PvP游戏,玩家可以互相竞争,回答问题。

上次我忙于自己的项目“ Botlify业务的聊天机器人的构造者”,您可以在指向 Habré 的链接上阅读其历史的第一部分。但是我决定分散自己的注意力,尝试将聊天机器人用于商业而非游戏。老实说,从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 bot 。大家平安!