¿Un prototipo de juego multijugador para las 3 pm?

¡Hola de nuevo! ¿A menudo se te ocurren ideas para proyectos que literalmente te impiden dormir? Esa sensación cuando te preocupas, te preocupas y no puedes trabajar en otras cosas normalmente. Me pasa varias veces al año. Algunas ideas desaparecen por sí solas después de profundizar en el tema y comprender que será extremadamente difícil beneficiarse de dicha iniciativa. Pero hay tales ideas, desarrolladas que incluso un par de horas, me capturan tanto que ya no puedo comer. Esta publicación trata sobre cómo logré realizar una de estas ideas en un par de noches después del trabajo y no morí de hambre. Pero la idea en sí misma inicialmente parecía bastante ambiciosa: un juego PvP en el que los jugadores compiten entre sí y responden preguntas.



" - 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 .


, , , " ", (, , ). , , ,
Programa de televisión original


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 . - , , - .


Realmente espero que mi publicación inspire a alguien a implementar sus ideas, hacer que los proyectos favoritos lo hagan más rápido y llevarlos al lanzamiento, ayudar a todo el proceso de creación de proyectos desde la idea hasta la implementación, o simplemente permitirle al menos un poco de placer de lectura. Estoy casi seguro de que el bot no resistirá una gran cantidad de jugadores. Aún así, temporizadores, suscriptores directamente en main, un proceso, etc., etc. Sin embargo, si alguien está interesado en ver el resultado, busque el bot @QuizMatchGameBot . ¡Paz para todos!


All Articles