مرحبا مجددا! هل غالبا ما تأتي بأفكار لمشاريع تمنعك حرفيا من النوم؟ هذا الشعور عندما تقلق ، تقلق ولا يمكنك العمل على أشياء أخرى بشكل طبيعي. يحدث لي عدة مرات في السنة. تختفي بعض الأفكار من تلقاء نفسها بعد الخوض في الموضوع وفهم أنه سيكون من الصعب للغاية الاستفادة من مثل هذه المبادرة. ولكن هناك مثل هذه الأفكار ، التي تتطور حتى بضع ساعات ، تجذبني كثيرًا لدرجة أنني لا أستطيع تناول الطعام بالفعل. يدور هذا المنشور حول كيف تمكنت من تحقيق إحدى هذه الأفكار في أمسيتين بعد العمل ولم أموت من الجوع. لكن الفكرة نفسها بدت طموحة في البداية - لعبة لاعب ضد لاعب يتنافس فيها اللاعبون مع بعضهم البعض ، ويجيبون على الأسئلة.

" - 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 bot . سلام للجميع!