рджреЛрдкрд╣рд░ 3 рдмрдЬреЗ рдХреЗ рд▓рд┐рдП рдПрдХ рдкреНрд░реЛрдЯреЛрдЯрд╛рдЗрдк рдорд▓реНрдЯреАрдкреНрд▓реЗрдпрд░ рдЧреЗрдо?

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



" - Botlify", . - , . , 14-15 , .



, 2013 . , , , "100 1". , , , . , , -. Telegram API, -.


, : -, , , ( , 1% тАФ YAGNI), , , . , , . . 3 . тАФ , - . , : " , , ". , . , , . тАФ . 10 , , , .


, , , Telegram.


1.


, . Telegram . . :


3 . . , . 6 , 3 .


  1. тАФ 8
  2. тАФ 5
  3. тАФ 3
  4. тАФ 2
  5. тАФ 1
  6. тАФ 1
  7. тАФ 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; // timestamp "" 
    lastGame: number|null; // timestamp    
}

, , - . , , "" 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 , . ? :


  • ID

, :


class QuestionService {

  /**
   * Get redis key of question by id
   * @param id
   */
  public static getQuestionKey(id: string) {
    return `questions:${id}`;
  }

  /**
   * Get redis key of questions list
   */
  public static getQuestionsListKey() {
    return 'questions';
  }

  /**
   * Return question object
   * @param id
   */
  async getQuestion(id: string) {
    return JSON.parse(
      await this.redisService.getClient().get(QuestionService.getQuestionKey(id))
    );
  }

  /**
   * Add new question to the store
   * @param question
   */
  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;
  }

  /**
   * Return ID of the random question
   */
  async getRandomQuestionId(): Promise<string> {
    return this.redisService.getClient()
      .srandmember(QuestionService.getQuestionsListKey());
  }

  /**
   * Return random question
   */
  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];

/**
 * Get key of list of the answers
 * @param questionId
 */
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);
}

/**
 * Get all answers for the given question
 * @param questionId
 */
async getQuestionAnswers(questionId: string): Promise<string[]> {
  return this.redisService.getClient()
    .zrevrange(QuestionService.getAnswersKey(questionId), 0, -1);
}

/**
 * Top 6 answers
 * @param questionId
 */
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);
}

/**
 * Find if answer already exists and return it if found
 * null if answer doesnt exist
 * @param questionId
 * @param answer
 */
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;
}

/**
 * Existing answer scores
 * @param questionId
 * @param answer
 */
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;
}

/**
 * Submit the new answer. Updates answer counter, save if doesn't exist, return answer score
 * @param questionId
 * @param answer
 */
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. , . , тАФ , - , , .


GameService
class 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 = {}; // . -, 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;
  }

  /**
   * Get redis key of game
   * @param id string identifier of game
   */
  public static getGameKey(id: string) {
    return `game:${id}`;
  }

  /**
   * Get game object by id
   * @param gameId
   */
  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);
  }

  /**
   * Save the game state to database
   * @param game
   */
  async saveGame(game: Game) {
    return this.redisService.getClient().set(
      GameService.getGameKey(game.id),
      JSON.stringify(game)
    );
  }

  /**
   * Save round
   * @param gameId
   * @param round
   */
  async saveRound(gameId: string, round: GameRound) {
    return this.redisService.getClient().set(
      GameService.getRoundKey(gameId, round.index),
      JSON.stringify(round)
    );
  }

  /**
   * Initialize default game structure, generate id
   * and save new game to the storage
   *
   * @param player1
   * @param player2
   */
  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;
  }

  /**
   * When the game is created and is in the  "Waiting for players" state
   * users can approve participation
   * @param playerId
   * @param gameId
   */
  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);
  }

  /**
   * Start the game
   * @param 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);
  }

  /**
   * Start the next round
   * @param 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);

    //        
    //     20    
    //        0 
    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})
    );
  }

  /**
   * Switch round to next turn
   * @param game
   * @param 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);
  }

  /**
   * Adds game score in specified game for specified player
   * @param gameId
   * @param playerId
   * @param score
   */
  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

  1. тАФ 8
  2. тАФ 5
  3. тАФ 3
  4. тАФ 2 .
  5. тАФ 1 .
  6. тАФ 1 .
  7. тАФ 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 рдмреЙрдЯ рджреЗрдЦреЗрдВ ред рд╕рднреА рдХреЗ рд▓рд┐рдП рд╢рд╛рдВрддрд┐!


All Articles