import { bindThis } from '@/decorators.js'; import loki from 'lokijs'; import Module, { InstalledModule } from '@/module.js'; import Message from '@/message.js'; import serifs from '@/serifs.js'; import type { User } from '@/misskey/user.js'; import { acct } from '@/utils/acct.js'; import 藍, { InstallerResult } from '@/ai.js'; type Game = { votes: { user: { id: string; username: string; host: User['host']; }; number: number; }[]; isEnded: boolean; startedAt: number; postId: string; }; const limitMinutes = 10; export default class extends Module { public readonly name = 'kazutori'; @bindThis public install(ai: 藍) { return new Installed(this, ai); } } class Installed extends InstalledModule { private games: loki.Collection; constructor(module: Module, ai: 藍) { super(module, ai); this.games = this.ai.getCollection('kazutori'); this.crawleGameEnd(); setInterval(this.crawleGameEnd, 1000); return this; } @bindThis public async mentionHook(msg: Message) { if (!msg.includes(['数取り'])) return false; const games = this.games.find({}); const recentGame = games.length == 0 ? null : games[games.length - 1]; if (recentGame) { // 現在アクティブなゲームがある場合 if (!recentGame.isEnded) { msg.reply(serifs.kazutori.alreadyStarted, { renote: recentGame.postId }); return true; } // 直近のゲームから1時間経ってない場合 if (Date.now() - recentGame.startedAt < 1000 * 60 * 60) { msg.reply(serifs.kazutori.matakondo); return true; } } const post = await this.ai.post({ text: serifs.kazutori.intro(limitMinutes) }); this.games.insertOne({ votes: [], isEnded: false, startedAt: Date.now(), postId: post.id }); this.subscribeReply(null, post.id); this.log('New kazutori game started'); return true; } @bindThis public async contextHook(key: any, msg: Message) { if (msg.text == null) return { reaction: 'hmm' }; const game = this.games.findOne({ isEnded: false }); // 処理の流れ上、実際にnullになることは無さそうだけど一応 if (game == null) return; // 既に数字を取っていたら if (game.votes.some(x => x.user.id == msg.userId)) return { reaction: 'confused' }; const match = msg.extractedText.match(/[0-9]+/); if (match == null) return { reaction: 'hmm' }; const num = parseInt(match[0], 10); // 整数じゃない if (!Number.isInteger(num)) return { reaction: 'hmm' }; // 範囲外 if (num < 0 || num > 100) return { reaction: 'confused' }; this.log(`Voted ${num} by ${msg.user.id}`); // 投票 game.votes.push({ user: { id: msg.user.id, username: msg.user.username, host: msg.user.host }, number: num }); this.games.update(game); return { reaction: 'like' }; } /** * 終了すべきゲームがないかチェック */ @bindThis private crawleGameEnd() { const game = this.games.findOne({ isEnded: false }); if (game == null) return; // 制限時間が経過していたら if (Date.now() - game.startedAt >= 1000 * 60 * limitMinutes) { this.finish(game); } } /** * ゲームを終わらせる */ @bindThis private finish(game: Game) { game.isEnded = true; this.games.update(game); this.log('Kazutori game finished'); // お流れ if (game.votes.length <= 1) { this.ai.post({ text: serifs.kazutori.onagare, renoteId: game.postId }); return; } let results: string[] = []; let winner: Game['votes'][0]['user'] | null = null; for (let i = 100; i >= 0; i--) { const users = game.votes .filter(x => x.number == i) .map(x => x.user); if (users.length == 1) { if (winner == null) { winner = users[0]; const icon = i == 100 ? '💯' : '🎉'; results.push(`${icon} **${i}**: $[jelly ${acct(users[0])}]`); } else { results.push(`➖ ${i}: ${acct(users[0])}`); } } else if (users.length > 1) { results.push(`❌ ${i}: ${users.map(u => acct(u)).join(' ')}`); } } const winnerFriend = winner ? this.ai.lookupFriend(winner.id) : null; const name = winnerFriend ? winnerFriend.name : null; const text = results.join('\n') + '\n\n' + (winner ? serifs.kazutori.finishWithWinner(acct(winner), name) : serifs.kazutori.finishWithNoWinner); this.ai.post({ text: text, cw: serifs.kazutori.finish, renoteId: game.postId }); this.unsubscribeReply(null); } }