From b7e161b6f6bc39d0a63c03d2ad085179394d1b09 Mon Sep 17 00:00:00 2001 From: SimolZimol <70102430+SimolZimol@users.noreply.github.com> Date: Sun, 26 Oct 2025 00:52:37 +0200 Subject: [PATCH] modified: .env.example modified: Dockerfile modified: app.py modified: requirements.txt --- .env.example | 13 +- Dockerfile | 7 + app.py | 432 ++++++++++++++++++++++++++++++++++++++++++++++- requirements.txt | 6 +- 4 files changed, 455 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 4e0f2f1..129b633 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,13 @@ # Discord Bot Token (wird in Coolify als Umgebungsvariable gesetzt) -DISCORD_TOKEN=your_discord_bot_token_here \ No newline at end of file +DISCORD_TOKEN=your_discord_bot_token_here + +# Database Connection - Option 1: Full URL (PostgreSQL/MySQL) +DATABASE_URL=postgresql://username:password@host:port/database_name +# DATABASE_URL=mysql://username:password@host:port/database_name + +# Database Connection - Option 2: Individual Variables +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=hoi4_elo +DB_USER=username +DB_PASSWORD=password \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9b425e5..94bfc4f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,13 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . +# Environment variables from Coolify ENV DISCORD_TOKEN=$DISCORD_TOKEN +ENV DATABASE_URL=$DATABASE_URL +ENV DB_HOST=$DB_HOST +ENV DB_PORT=$DB_PORT +ENV DB_NAME=$DB_NAME +ENV DB_USER=$DB_USER +ENV DB_PASSWORD=$DB_PASSWORD CMD ["python", "app.py"] diff --git a/app.py b/app.py index bbbe66b..d56ca1f 100644 --- a/app.py +++ b/app.py @@ -4,6 +4,10 @@ import os import asyncio import logging from dotenv import load_dotenv +import asyncpg +import json +from datetime import datetime +from typing import Optional, List, Dict # Load environment variables load_dotenv() @@ -11,6 +15,12 @@ load_dotenv() # Configure logging logging.basicConfig(level=logging.INFO) +# Database configuration +DATABASE_URL = os.getenv('DATABASE_URL') + +# Global database connection pool +db_pool = None + # Bot configuration intents = discord.Intents.default() intents.message_content = True @@ -27,9 +37,12 @@ async def on_ready(): print(f'Discord.py Version: {discord.__version__}') print('------') + # Initialize database + await init_database() + # Set bot status await bot.change_presence( - activity=discord.Game(name="Hearts of Iron IV"), + activity=discord.Game(name="Hearts of Iron IV ELO"), status=discord.Status.online ) @@ -53,6 +66,107 @@ async def on_guild_remove(guild): # Owner Configuration OWNER_ID = 253922739709018114 +# ELO Configuration +STARTING_ELO = 800 +K_FACTOR = 32 +T_LEVEL_MULTIPLIERS = { + 1: 0.8, # T1 countries get less points + 2: 1.0, # T2 countries get normal points + 3: 1.2 # T3 countries get more points +} + +# Database Functions +async def init_database(): + """Initialize database connection and create tables""" + global db_pool + try: + db_pool = await asyncpg.create_pool(DATABASE_URL) + + async with db_pool.acquire() as conn: + # Create players table + await conn.execute(''' + CREATE TABLE IF NOT EXISTS players ( + id SERIAL PRIMARY KEY, + discord_id BIGINT UNIQUE NOT NULL, + username VARCHAR(255) NOT NULL, + standard_elo INTEGER DEFAULT 800, + competitive_elo INTEGER DEFAULT 800, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Create games table + await conn.execute(''' + CREATE TABLE IF NOT EXISTS games ( + id SERIAL PRIMARY KEY, + game_name VARCHAR(255) NOT NULL, + game_type VARCHAR(50) NOT NULL, + status VARCHAR(50) DEFAULT 'setup', + players JSONB NOT NULL DEFAULT '[]', + winner_team VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP + ) + ''') + + # Create game_results table for detailed match history + await conn.execute(''' + CREATE TABLE IF NOT EXISTS game_results ( + id SERIAL PRIMARY KEY, + game_id INTEGER REFERENCES games(id), + discord_id BIGINT NOT NULL, + team_name VARCHAR(255) NOT NULL, + t_level INTEGER NOT NULL, + old_elo INTEGER NOT NULL, + new_elo INTEGER NOT NULL, + elo_change INTEGER NOT NULL, + won BOOLEAN NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + print("✅ Database initialized successfully") + + except Exception as e: + print(f"❌ Database initialization failed: {e}") + +async def get_or_create_player(discord_id: int, username: str) -> Dict: + """Get or create a player in the database""" + async with db_pool.acquire() as conn: + # Try to get existing player + player = await conn.fetchrow( + "SELECT * FROM players WHERE discord_id = $1", discord_id + ) + + if not player: + # Create new player + await conn.execute( + "INSERT INTO players (discord_id, username) VALUES ($1, $2)", + discord_id, username + ) + player = await conn.fetchrow( + "SELECT * FROM players WHERE discord_id = $1", discord_id + ) + else: + # Update username if changed + await conn.execute( + "UPDATE players SET username = $1, updated_at = CURRENT_TIMESTAMP WHERE discord_id = $2", + username, discord_id + ) + + return dict(player) + +def calculate_elo_change(player_elo: int, opponent_avg_elo: int, won: bool, t_level: int) -> int: + """Calculate ELO change using standard ELO formula with T-level multiplier""" + expected_score = 1 / (1 + 10 ** ((opponent_avg_elo - player_elo) / 400)) + actual_score = 1 if won else 0 + + base_change = K_FACTOR * (actual_score - expected_score) + t_multiplier = T_LEVEL_MULTIPLIERS.get(t_level, 1.0) + + return round(base_change * t_multiplier) + # Owner only decorator def is_owner(): def predicate(ctx): @@ -96,6 +210,314 @@ async def reload_bot(ctx): ) await ctx.send(embed=embed) +# HOI4 ELO Commands +@bot.hybrid_command(name='hoi4create', description='Create a new HOI4 game') +async def hoi4create(ctx, game_type: str, game_name: str): + """Create a new HOI4 game""" + if game_type.lower() not in ['standard', 'competitive']: + await ctx.send("❌ Game type must be either 'standard' or 'competitive'") + return + + try: + async with db_pool.acquire() as conn: + # Check if game name already exists and is active + existing_game = await conn.fetchrow( + "SELECT * FROM games WHERE game_name = $1 AND status = 'setup'", + game_name + ) + + if existing_game: + await ctx.send(f"❌ A game with name '{game_name}' is already in setup phase!") + return + + # Create new game + await conn.execute( + "INSERT INTO games (game_name, game_type, status) VALUES ($1, $2, 'setup')", + game_name, game_type.lower() + ) + + embed = discord.Embed( + title="🎮 Game Created", + description=f"HOI4 {game_type.title()} game '{game_name}' has been created!", + color=discord.Color.green() + ) + embed.add_field(name="Game Name", value=game_name, inline=True) + embed.add_field(name="Type", value=game_type.title(), inline=True) + embed.add_field(name="Status", value="Setup Phase", inline=True) + embed.set_footer(text="Use /hoi4setup to add players to this game") + + await ctx.send(embed=embed) + + except Exception as e: + await ctx.send(f"❌ Error creating game: {str(e)}") + +@bot.hybrid_command(name='hoi4setup', description='Add a player to an existing game') +async def hoi4setup(ctx, game_name: str, user: discord.Member, team_name: str, t_level: int): + """Add a player to an existing game""" + if t_level not in [1, 2, 3]: + await ctx.send("❌ T-Level must be 1, 2, or 3") + return + + try: + async with db_pool.acquire() as conn: + # Get the game + game = await conn.fetchrow( + "SELECT * FROM games WHERE game_name = $1 AND status = 'setup'", + game_name + ) + + if not game: + await ctx.send(f"❌ No game found with name '{game_name}' in setup phase!") + return + + # Get or create player + player = await get_or_create_player(user.id, user.display_name) + + # Parse existing players + players = json.loads(game['players']) if game['players'] else [] + + # Check if player already in game + for p in players: + if p['discord_id'] == user.id: + await ctx.send(f"❌ {user.display_name} is already in this game!") + return + + # Add player to game + player_data = { + 'discord_id': user.id, + 'username': user.display_name, + 'team_name': team_name, + 't_level': t_level, + 'current_elo': player[f"{game['game_type']}_elo"] + } + players.append(player_data) + + # Update game + await conn.execute( + "UPDATE games SET players = $1 WHERE id = $2", + json.dumps(players), game['id'] + ) + + embed = discord.Embed( + title="✅ Player Added", + description=f"{user.display_name} has been added to '{game_name}'!", + color=discord.Color.green() + ) + embed.add_field(name="Player", value=user.display_name, inline=True) + embed.add_field(name="Team", value=team_name, inline=True) + embed.add_field(name="T-Level", value=f"T{t_level}", inline=True) + embed.add_field(name="Current ELO", value=player[f"{game['game_type']}_elo"], inline=True) + embed.add_field(name="Players in Game", value=len(players), inline=True) + + await ctx.send(embed=embed) + + except Exception as e: + await ctx.send(f"❌ Error adding player: {str(e)}") + +@bot.hybrid_command(name='hoi4end', description='End a game and calculate ELO changes') +async def hoi4end(ctx, game_name: str, winner_team: str): + """End a game and calculate ELO changes""" + try: + async with db_pool.acquire() as conn: + # Get the game + game = await conn.fetchrow( + "SELECT * FROM games WHERE game_name = $1 AND status = 'setup'", + game_name + ) + + if not game: + await ctx.send(f"❌ No active game found with name '{game_name}'!") + return + + players = json.loads(game['players']) if game['players'] else [] + + if len(players) < 2: + await ctx.send("❌ Game needs at least 2 players to end!") + return + + # Check if winner team exists + teams = {p['team_name'] for p in players} + if winner_team not in teams: + await ctx.send(f"❌ Team '{winner_team}' not found in game! Available teams: {', '.join(teams)}") + return + + # Calculate team averages + team_elos = {} + team_players = {} + + for player in players: + team = player['team_name'] + if team not in team_elos: + team_elos[team] = [] + team_players[team] = [] + + team_elos[team].append(player['current_elo']) + team_players[team].append(player) + + # Calculate average ELOs for each team + team_averages = {team: sum(elos) / len(elos) for team, elos in team_elos.items()} + + elo_changes = [] + + # Calculate ELO changes for each player + for player in players: + team = player['team_name'] + won = team == winner_team + + # Calculate opponent average (average of all other teams) + opponent_elos = [] + for other_team, elos in team_elos.items(): + if other_team != team: + opponent_elos.extend(elos) + + opponent_avg = sum(opponent_elos) / len(opponent_elos) if opponent_elos else player['current_elo'] + + elo_change = calculate_elo_change( + player['current_elo'], + opponent_avg, + won, + player['t_level'] + ) + + new_elo = max(0, player['current_elo'] + elo_change) # Prevent negative ELO + + elo_changes.append({ + 'discord_id': player['discord_id'], + 'username': player['username'], + 'team_name': team, + 't_level': player['t_level'], + 'old_elo': player['current_elo'], + 'new_elo': new_elo, + 'elo_change': elo_change, + 'won': won + }) + + # Update player ELOs and save game results + for change in elo_changes: + # Update player ELO + elo_field = f"{game['game_type']}_elo" + await conn.execute( + f"UPDATE players SET {elo_field} = $1, updated_at = CURRENT_TIMESTAMP WHERE discord_id = $2", + change['new_elo'], change['discord_id'] + ) + + # Save game result + await conn.execute( + """INSERT INTO game_results + (game_id, discord_id, team_name, t_level, old_elo, new_elo, elo_change, won) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)""", + game['id'], change['discord_id'], change['team_name'], + change['t_level'], change['old_elo'], change['new_elo'], + change['elo_change'], change['won'] + ) + + # Mark game as finished + await conn.execute( + "UPDATE games SET status = 'finished', winner_team = $1, finished_at = CURRENT_TIMESTAMP WHERE id = $2", + winner_team, game['id'] + ) + + # Create result embed + embed = discord.Embed( + title="🏆 Game Finished!", + description=f"Game '{game_name}' has ended!\n**Winner: {winner_team}**", + color=discord.Color.gold() + ) + + # Group results by team + teams_results = {} + for change in elo_changes: + team = change['team_name'] + if team not in teams_results: + teams_results[team] = [] + teams_results[team].append(change) + + for team, team_changes in teams_results.items(): + team_text = "" + for change in team_changes: + emoji = "📈" if change['elo_change'] > 0 else "📉" if change['elo_change'] < 0 else "➡️" + team_text += f"{change['username']}: {change['old_elo']} → {change['new_elo']} ({change['elo_change']:+d}) {emoji}\n" + + embed.add_field( + name=f"{'🏆 ' if team == winner_team else ''}Team {team}", + value=team_text, + inline=False + ) + + embed.set_footer(text=f"Game Type: {game['game_type'].title()}") + + await ctx.send(embed=embed) + + except Exception as e: + await ctx.send(f"❌ Error ending game: {str(e)}") + +@bot.hybrid_command(name='hoi4stats', description='Show your HOI4 ELO statistics') +async def hoi4stats(ctx, user: Optional[discord.Member] = None): + """Show HOI4 ELO statistics for a user""" + target_user = user or ctx.author + + try: + player = await get_or_create_player(target_user.id, target_user.display_name) + + embed = discord.Embed( + title=f"📊 HOI4 ELO Stats - {target_user.display_name}", + color=discord.Color.blue() + ) + + embed.add_field(name="Standard ELO", value=f"🎯 {player['standard_elo']}", inline=True) + embed.add_field(name="Competitive ELO", value=f"🏆 {player['competitive_elo']}", inline=True) + embed.add_field(name="Player Since", value=player['created_at'].strftime("%m/%d/%Y"), inline=True) + + if target_user.avatar: + embed.set_thumbnail(url=target_user.avatar.url) + + await ctx.send(embed=embed) + + except Exception as e: + await ctx.send(f"❌ Error getting stats: {str(e)}") + +@bot.hybrid_command(name='hoi4games', description='Show active games') +async def hoi4games(ctx): + """Show all active games""" + try: + async with db_pool.acquire() as conn: + games = await conn.fetch( + "SELECT * FROM games WHERE status = 'setup' ORDER BY created_at DESC" + ) + + if not games: + await ctx.send("📝 No active games found. Use `/hoi4create` to create a new game!") + return + + embed = discord.Embed( + title="🎮 Active HOI4 Games", + color=discord.Color.green() + ) + + for game in games: + players = json.loads(game['players']) if game['players'] else [] + player_count = len(players) + + teams = {} + for player in players: + team = player['team_name'] + if team not in teams: + teams[team] = 0 + teams[team] += 1 + + team_info = ", ".join([f"{team} ({count})" for team, count in teams.items()]) if teams else "No players yet" + + embed.add_field( + name=f"{game['game_name']} ({game['game_type'].title()})", + value=f"Players: {player_count}\nTeams: {team_info}", + inline=False + ) + + await ctx.send(embed=embed) + + except Exception as e: + await ctx.send(f"❌ Error getting games: {str(e)}") + @bot.event async def on_command_error(ctx, error): """Handles command errors""" @@ -122,6 +544,11 @@ async def main(): print("Please set the DISCORD_TOKEN variable in Coolify or create a .env file") return + if not DATABASE_URL: + print("❌ DATABASE_URL environment variable not found!") + print("Please set the DATABASE_URL variable in Coolify") + return + try: print("🚀 Starting bot...") await bot.start(token) @@ -129,6 +556,9 @@ async def main(): print("❌ Invalid Discord token!") except Exception as e: print(f"❌ Error starting bot: {e}") + finally: + if db_pool: + await db_pool.close() if __name__ == "__main__": asyncio.run(main()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e4a9937..90db89a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,7 @@ discord.py==2.3.2 python-dotenv==1.0.0 -aiohttp==3.9.1 \ No newline at end of file +aiohttp==3.9.1 +asyncpg==0.29.0 +psycopg2-binary==2.9.9 +aiomysql==0.2.0 +PyMySQL==1.1.0 \ No newline at end of file