import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type APIEmbed } from 'discord.js'; import { HANGMAN_WORDS, randomInt, TRIVIA_QUESTIONS, type Locale, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; export const FUN_BUTTON_PREFIX = 'fun:'; export const TRIVIA_TTL_SECONDS = 120; export const GAME_TTL_SECONDS = 3600; const TRIVIA_KEY_PREFIX = 'fun:trivia:'; const TTT_KEY_PREFIX = 'fun:ttt:'; const C4_KEY_PREFIX = 'fun:c4:'; const HM_KEY_PREFIX = 'fun:hm:'; export type TicTacToeMark = 'X' | 'O'; export type TicTacToeCell = TicTacToeMark | null; export type TicTacToeState = { gameId: string; guildId: string; channelId: string; playerX: string; playerO: string; board: TicTacToeCell[]; current: TicTacToeMark; status: 'active' | 'draw' | 'won'; winner?: TicTacToeMark; }; export type Connect4Mark = 'R' | 'Y'; export type Connect4Cell = Connect4Mark | null; export type Connect4State = { gameId: string; guildId: string; channelId: string; playerRed: string; playerYellow: string; board: Connect4Cell[][]; current: Connect4Mark; status: 'active' | 'draw' | 'won'; winner?: Connect4Mark; }; export type HangmanState = { gameId: string; guildId: string; userId: string; word: string; guessed: string[]; wrongGuesses: number; maxWrong: number; status: 'active' | 'won' | 'lost'; letterPage: number; }; export type TriviaSession = { userId: string; guildId: string; questionIndex: number; correctIndex: number; }; const TTT_LINES = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6] ] as const; const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); export function createGameId(): string { return crypto.randomUUID().replace(/-/g, '').slice(0, 12); } export function checkTicTacToeWinner(board: TicTacToeCell[]): TicTacToeMark | null { for (const [a, b, c] of TTT_LINES) { const mark = board[a]; if (mark && mark === board[b] && mark === board[c]) { return mark; } } return null; } export function currentTicTacToePlayerId(state: TicTacToeState): string { return state.current === 'X' ? state.playerX : state.playerO; } export function applyTicTacToeMove(state: TicTacToeState, cell: number): TicTacToeState { if (state.status !== 'active' || state.board[cell] !== null) { return state; } const board = [...state.board]; board[cell] = state.current; const winner = checkTicTacToeWinner(board); if (winner) { return { ...state, board, status: 'won', winner }; } if (board.every((value) => value !== null)) { return { ...state, board, status: 'draw' }; } return { ...state, board, current: state.current === 'X' ? 'O' : 'X' }; } export function createConnect4Board(): Connect4Cell[][] { return Array.from({ length: 6 }, () => Array.from({ length: 7 }, () => null)); } function connect4DropRow(board: Connect4Cell[][], col: number): number { for (let row = board.length - 1; row >= 0; row -= 1) { if (board[row]![col] === null) { return row; } } return -1; } function connect4ColumnFull(board: Connect4Cell[][], col: number): boolean { return board[0]![col] !== null; } export function applyConnect4Move(state: Connect4State, col: number): Connect4State { if (state.status !== 'active' || col < 0 || col >= 7 || connect4ColumnFull(state.board, col)) { return state; } const row = connect4DropRow(state.board, col); if (row < 0) { return state; } const board = state.board.map((line) => [...line]); board[row]![col] = state.current; const winner = checkConnect4Winner(board); if (winner) { return { ...state, board, status: 'won', winner }; } if (board.every((line) => line.every((cell) => cell !== null))) { return { ...state, board, status: 'draw' }; } return { ...state, board, current: state.current === 'R' ? 'Y' : 'R' }; } export function checkConnect4Winner(board: Connect4Cell[][]): Connect4Mark | null { const directions = [ [0, 1], [1, 0], [1, 1], [1, -1] ] as const; for (let row = 0; row < 6; row += 1) { for (let col = 0; col < 7; col += 1) { const start = board[row]![col]; if (!start) { continue; } for (const [dr, dc] of directions) { let matches = 1; for (let step = 1; step < 4; step += 1) { const next = board[row + dr * step]?.[col + dc * step]; if (next !== start) { break; } matches += 1; } if (matches >= 4) { return start; } } } } return null; } export function currentConnect4PlayerId(state: Connect4State): string { return state.current === 'R' ? state.playerRed : state.playerYellow; } export function pickHangmanWord(locale: Locale): string { const words = HANGMAN_WORDS[locale]; return words[randomInt(0, words.length - 1)]!; } export function renderHangmanWord(word: string, guessed: string[]): string { return word .split('') .map((letter) => (guessed.includes(letter) ? letter : '⬜')) .join(' '); } export function applyHangmanGuess(state: HangmanState, letter: string): HangmanState { const normalized = letter.toUpperCase(); if ( state.status !== 'active' || normalized.length !== 1 || !/^[A-Z]$/.test(normalized) || state.guessed.includes(normalized) ) { return state; } const guessed = [...state.guessed, normalized]; const inWord = state.word.includes(normalized); const wrongGuesses = inWord ? state.wrongGuesses : state.wrongGuesses + 1; const revealed = state.word.split('').every((char) => guessed.includes(char)); if (revealed) { return { ...state, guessed, wrongGuesses, status: 'won' }; } if (wrongGuesses >= state.maxWrong) { return { ...state, guessed, wrongGuesses, status: 'lost' }; } return { ...state, guessed, wrongGuesses }; } async function saveJson(redis: BotContext['redis'], key: string, value: unknown, ttl: number): Promise { await redis.set(key, JSON.stringify(value), 'EX', ttl); } async function loadJson(redis: BotContext['redis'], key: string): Promise { const raw = await redis.get(key); if (!raw) { return null; } return JSON.parse(raw) as T; } export async function saveTriviaSession(context: BotContext, session: TriviaSession): Promise { await saveJson(context.redis, `${TRIVIA_KEY_PREFIX}${session.userId}`, session, TRIVIA_TTL_SECONDS); } export async function loadTriviaSession(context: BotContext, userId: string): Promise { return loadJson(context.redis, `${TRIVIA_KEY_PREFIX}${userId}`); } export async function deleteTriviaSession(context: BotContext, userId: string): Promise { await context.redis.del(`${TRIVIA_KEY_PREFIX}${userId}`); } export async function saveTicTacToeState(context: BotContext, state: TicTacToeState): Promise { await saveJson(context.redis, `${TTT_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS); } export async function loadTicTacToeState(context: BotContext, gameId: string): Promise { return loadJson(context.redis, `${TTT_KEY_PREFIX}${gameId}`); } export async function deleteTicTacToeState(context: BotContext, gameId: string): Promise { await context.redis.del(`${TTT_KEY_PREFIX}${gameId}`); } export async function saveConnect4State(context: BotContext, state: Connect4State): Promise { await saveJson(context.redis, `${C4_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS); } export async function loadConnect4State(context: BotContext, gameId: string): Promise { return loadJson(context.redis, `${C4_KEY_PREFIX}${gameId}`); } export async function deleteConnect4State(context: BotContext, gameId: string): Promise { await context.redis.del(`${C4_KEY_PREFIX}${gameId}`); } export async function saveHangmanState(context: BotContext, state: HangmanState): Promise { await saveJson(context.redis, `${HM_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS); } export async function loadHangmanState(context: BotContext, gameId: string): Promise { return loadJson(context.redis, `${HM_KEY_PREFIX}${gameId}`); } export async function deleteHangmanState(context: BotContext, gameId: string): Promise { await context.redis.del(`${HM_KEY_PREFIX}${gameId}`); } function tttCellLabel(cell: TicTacToeCell): string { if (cell === 'X') return '❌'; if (cell === 'O') return '⭕'; return '⬜'; } export function buildTicTacToeEmbed(locale: Locale, state: TicTacToeState): APIEmbed { const boardLines = [0, 1, 2] .map((row) => state.board.slice(row * 3, row * 3 + 3).map(tttCellLabel).join(' ')) .join('\n'); let description = tf(locale, 'fun.tictactoe.board', { x: `<@${state.playerX}>`, o: `<@${state.playerO}>`, board: boardLines }); if (state.status === 'won' && state.winner) { const winnerId = state.winner === 'X' ? state.playerX : state.playerO; description += `\n\n${tf(locale, 'fun.tictactoe.won', { user: `<@${winnerId}>` })}`; } else if (state.status === 'draw') { description += `\n\n${tf(locale, 'fun.tictactoe.draw', {})}`; } else { description += `\n\n${tf(locale, 'fun.tictactoe.turn', { user: `<@${currentTicTacToePlayerId(state)}>` })}`; } return { title: tf(locale, 'fun.tictactoe.title', {}), description, color: 0x6366f1 }; } export function buildTicTacToeComponents(state: TicTacToeState): ActionRowBuilder[] { if (state.status !== 'active') { return []; } const rows: ActionRowBuilder[] = []; for (let row = 0; row < 3; row += 1) { const builder = new ActionRowBuilder(); for (let col = 0; col < 3; col += 1) { const cell = row * 3 + col; const mark = state.board[cell]; builder.addComponents( new ButtonBuilder() .setCustomId(`${FUN_BUTTON_PREFIX}ttt:${state.gameId}:${cell}`) .setLabel(mark ?? String(cell + 1)) .setStyle(mark ? ButtonStyle.Secondary : ButtonStyle.Primary) .setDisabled(mark !== null) ); } rows.push(builder); } return rows; } function c4CellLabel(cell: Connect4Cell): string { if (cell === 'R') return '🔴'; if (cell === 'Y') return '🟡'; return '⚪'; } export function buildConnect4Embed(locale: Locale, state: Connect4State): APIEmbed { const boardText = [...state.board] .reverse() .map((row) => row.map(c4CellLabel).join('')) .join('\n'); let description = tf(locale, 'fun.connect4.board', { red: `<@${state.playerRed}>`, yellow: `<@${state.playerYellow}>`, board: boardText }); if (state.status === 'won' && state.winner) { const winnerId = state.winner === 'R' ? state.playerRed : state.playerYellow; description += `\n\n${tf(locale, 'fun.connect4.won', { user: `<@${winnerId}>` })}`; } else if (state.status === 'draw') { description += `\n\n${tf(locale, 'fun.connect4.draw', {})}`; } else { description += `\n\n${tf(locale, 'fun.connect4.turn', { user: `<@${currentConnect4PlayerId(state)}>` })}`; } return { title: tf(locale, 'fun.connect4.title', {}), description, color: 0x6366f1 }; } export function buildConnect4Components(state: Connect4State): ActionRowBuilder[] { if (state.status !== 'active') { return []; } const row = new ActionRowBuilder(); for (let col = 0; col < 7; col += 1) { row.addComponents( new ButtonBuilder() .setCustomId(`${FUN_BUTTON_PREFIX}c4:${state.gameId}:${col}`) .setLabel(String(col + 1)) .setStyle(ButtonStyle.Primary) .setDisabled(connect4ColumnFull(state.board, col)) ); } return [row]; } export function buildHangmanEmbed(locale: Locale, state: HangmanState): APIEmbed { let description = tf(locale, 'fun.hangman.progress', { word: renderHangmanWord(state.word, state.guessed), wrong: state.wrongGuesses, max: state.maxWrong, guessed: state.guessed.length > 0 ? state.guessed.join(', ') : '—' }); if (state.status === 'won') { description += `\n\n${tf(locale, 'fun.hangman.won', { word: state.word })}`; } else if (state.status === 'lost') { description += `\n\n${tf(locale, 'fun.hangman.lost', { word: state.word })}`; } return { title: tf(locale, 'fun.hangman.title', {}), description, color: 0x6366f1 }; } function hangmanLettersForPage(state: HangmanState): { letters: string[]; page: number; totalPages: number } { const remaining = ALPHABET.filter((letter) => !state.guessed.includes(letter)); const lettersPerPage = 24; const totalPages = Math.max(1, Math.ceil(remaining.length / lettersPerPage)); const page = Math.min(state.letterPage, totalPages - 1); const start = page * lettersPerPage; return { letters: remaining.slice(start, start + lettersPerPage), page, totalPages }; } export function buildHangmanComponents(state: HangmanState): ActionRowBuilder[] { if (state.status !== 'active') { return []; } const { letters, page, totalPages } = hangmanLettersForPage(state); const rows: ActionRowBuilder[] = []; for (let i = 0; i < letters.length; i += 5) { const chunk = letters.slice(i, i + 5); const row = new ActionRowBuilder(); for (const letter of chunk) { row.addComponents( new ButtonBuilder() .setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:${letter}`) .setLabel(letter) .setStyle(ButtonStyle.Primary) ); } rows.push(row); if (rows.length >= 4) { break; } } const navRow = new ActionRowBuilder(); if (totalPages > 1) { navRow.addComponents( new ButtonBuilder() .setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:page:${Math.max(0, page - 1)}`) .setLabel('◀') .setStyle(ButtonStyle.Secondary) .setDisabled(page <= 0), new ButtonBuilder() .setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:page:${Math.min(totalPages - 1, page + 1)}`) .setLabel('▶') .setStyle(ButtonStyle.Secondary) .setDisabled(page >= totalPages - 1) ); } if (navRow.components.length > 0) { rows.push(navRow); } return rows.slice(0, 5); } export function pickTriviaQuestion(): (typeof TRIVIA_QUESTIONS)[number] { return TRIVIA_QUESTIONS[randomInt(0, TRIVIA_QUESTIONS.length - 1)]!; } export function buildTriviaEmbed( locale: Locale, question: (typeof TRIVIA_QUESTIONS)[number], userId: string ): APIEmbed { const options = question.options[locale] .map((option, index) => `${String.fromCharCode(65 + index)}. ${option}`) .join('\n'); return { title: tf(locale, 'fun.trivia.title', {}), description: tf(locale, 'fun.trivia.question', { question: question.q[locale], options }), footer: { text: tf(locale, 'fun.trivia.footer', { user: `<@${userId}>` }) }, color: 0x6366f1 }; } export function buildTriviaComponents( userId: string, correctIndex: number, locale: Locale, question: (typeof TRIVIA_QUESTIONS)[number] ): ActionRowBuilder[] { const labels = ['A', 'B', 'C', 'D']; const row = new ActionRowBuilder(); for (let index = 0; index < question.options[locale].length; index += 1) { row.addComponents( new ButtonBuilder() .setCustomId(`${FUN_BUTTON_PREFIX}trivia:${userId}:${index}:${correctIndex}`) .setLabel(`${labels[index]!}. ${question.options[locale][index]!.slice(0, 20)}`) .setStyle(ButtonStyle.Primary) ); } return [row]; } export function parseTriviaButton(customId: string): { userId: string; answerIndex: number; correctIndex: number; } | null { const parts = customId.split(':'); if (parts.length !== 5 || parts[0] !== 'fun' || parts[1] !== 'trivia') { return null; } const userId = parts[2]; const answerIndex = Number(parts[3]); const correctIndex = Number(parts[4]); if (!userId || Number.isNaN(answerIndex) || Number.isNaN(correctIndex)) { return null; } return { userId, answerIndex, correctIndex }; } export function parseTicTacToeButton(customId: string): { gameId: string; cell: number } | null { const parts = customId.split(':'); if (parts.length !== 4 || parts[0] !== 'fun' || parts[1] !== 'ttt') { return null; } const gameId = parts[2]; const cell = Number(parts[3]); if (!gameId || Number.isNaN(cell) || cell < 0 || cell > 8) { return null; } return { gameId, cell }; } export function parseConnect4Button(customId: string): { gameId: string; col: number } | null { const parts = customId.split(':'); if (parts.length !== 4 || parts[0] !== 'fun' || parts[1] !== 'c4') { return null; } const gameId = parts[2]; const col = Number(parts[3]); if (!gameId || Number.isNaN(col) || col < 0 || col > 6) { return null; } return { gameId, col }; } export function parseHangmanButton( customId: string ): { gameId: string; letter?: string; page?: number } | null { const parts = customId.split(':'); if (parts.length < 4 || parts[0] !== 'fun' || parts[1] !== 'hm') { return null; } const gameId = parts[2]; if (!gameId) { return null; } if (parts[3] === 'page') { const page = Number(parts[4]); if (Number.isNaN(page) || page < 0) { return null; } return { gameId, page }; } const letter = parts[3]?.toUpperCase(); if (!letter || !/^[A-Z]$/.test(letter)) { return null; } return { gameId, letter }; }