Implement leveling and economy features with associated models and commands

- Added new models for leveling and economy functionalities in the Prisma schema, including LevelingConfig, MemberLevel, LevelReward, EconomyConfig, MemberEconomy, ShopItem, and InventoryItem.
- Introduced commands for managing XP, economy transactions, and shop interactions, enhancing user engagement.
- Updated job handling to include reminders and poll closing functionalities.
- Enhanced localization support for new commands and features in both German and English.
- Improved the bot's job processing capabilities with a dedicated reminder worker for scheduled tasks.
This commit is contained in:
smueller
2026-07-22 12:49:17 +02:00
parent 2518119257
commit fa5910ec43
38 changed files with 6759 additions and 144 deletions

View File

@@ -0,0 +1,574 @@
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<void> {
await redis.set(key, JSON.stringify(value), 'EX', ttl);
}
async function loadJson<T>(redis: BotContext['redis'], key: string): Promise<T | null> {
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<void> {
await saveJson(context.redis, `${TRIVIA_KEY_PREFIX}${session.userId}`, session, TRIVIA_TTL_SECONDS);
}
export async function loadTriviaSession(context: BotContext, userId: string): Promise<TriviaSession | null> {
return loadJson<TriviaSession>(context.redis, `${TRIVIA_KEY_PREFIX}${userId}`);
}
export async function deleteTriviaSession(context: BotContext, userId: string): Promise<void> {
await context.redis.del(`${TRIVIA_KEY_PREFIX}${userId}`);
}
export async function saveTicTacToeState(context: BotContext, state: TicTacToeState): Promise<void> {
await saveJson(context.redis, `${TTT_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
}
export async function loadTicTacToeState(context: BotContext, gameId: string): Promise<TicTacToeState | null> {
return loadJson<TicTacToeState>(context.redis, `${TTT_KEY_PREFIX}${gameId}`);
}
export async function deleteTicTacToeState(context: BotContext, gameId: string): Promise<void> {
await context.redis.del(`${TTT_KEY_PREFIX}${gameId}`);
}
export async function saveConnect4State(context: BotContext, state: Connect4State): Promise<void> {
await saveJson(context.redis, `${C4_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
}
export async function loadConnect4State(context: BotContext, gameId: string): Promise<Connect4State | null> {
return loadJson<Connect4State>(context.redis, `${C4_KEY_PREFIX}${gameId}`);
}
export async function deleteConnect4State(context: BotContext, gameId: string): Promise<void> {
await context.redis.del(`${C4_KEY_PREFIX}${gameId}`);
}
export async function saveHangmanState(context: BotContext, state: HangmanState): Promise<void> {
await saveJson(context.redis, `${HM_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
}
export async function loadHangmanState(context: BotContext, gameId: string): Promise<HangmanState | null> {
return loadJson<HangmanState>(context.redis, `${HM_KEY_PREFIX}${gameId}`);
}
export async function deleteHangmanState(context: BotContext, gameId: string): Promise<void> {
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<ButtonBuilder>[] {
if (state.status !== 'active') {
return [];
}
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
for (let row = 0; row < 3; row += 1) {
const builder = new ActionRowBuilder<ButtonBuilder>();
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<ButtonBuilder>[] {
if (state.status !== 'active') {
return [];
}
const row = new ActionRowBuilder<ButtonBuilder>();
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<ButtonBuilder>[] {
if (state.status !== 'active') {
return [];
}
const { letters, page, totalPages } = hangmanLettersForPage(state);
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
for (let i = 0; i < letters.length; i += 5) {
const chunk = letters.slice(i, i + 5);
const row = new ActionRowBuilder<ButtonBuilder>();
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<ButtonBuilder>();
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<ButtonBuilder>[] {
const labels = ['A', 'B', 'C', 'D'];
const row = new ActionRowBuilder<ButtonBuilder>();
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 };
}