diff --git a/README.md b/README.md index 475a7f8..e38c101 100644 --- a/README.md +++ b/README.md @@ -18,4 +18,4 @@ ToDo: - add status description to member cards -For now let's pivot to the Discord Bot functionality. What the bot is going to do is for each battle group it needs to assign a friendly member to an enemy member. That friendly will then get a ping in Discord by pulling the list of Discord users and matching the player id to the user in the Discord server. It will ping them and say "New target for @user , attack (link to enemy profile) in the next 60 seconds!" If the enemies status does not change to "In Hospital" in the next 60 seconds that enemy will be assigned to the next player in the group that has not received a hit yet. We will also need to keep track of how many hits a friendly has completed. That way if a new friendly enters a pool they will get a chance to attack before the ones that have not had a chance We also need a button on the webpage to start and stop the bot \ No newline at end of file +For now let's pivot to the Discord Bot functionality. What the bot is going to do is for each battle group it needs to assign a friendly member to an enemy member. That friendly will then get a ping in Discord by pulling the list of Discord users and matching the player id to the user in the Discord server. It will ping them and say "New target for @user , attack (link to enemy profile) in the next 30 seconds!" If the enemies status does not change to "In Hospital" in the next 30 seconds that enemy will be assigned to the next player in the group that has not received a hit yet. We will also need to keep track of how many hits a friendly has completed. That way if a new friendly enters a pool they will get a chance to attack before the ones that have not had a chance. We also need a button on the webpage to start and stop the bot \ No newline at end of file diff --git a/__pycache__/config.cpython-311.pyc b/__pycache__/config.cpython-311.pyc index 4411a3c..977635f 100644 Binary files a/__pycache__/config.cpython-311.pyc and b/__pycache__/config.cpython-311.pyc differ diff --git a/config.py b/config.py index 98f8668..726c416 100644 --- a/config.py +++ b/config.py @@ -7,6 +7,9 @@ ALLOWED_CHANNEL_ID = 1442876328536707316 # FFScouter API FFSCOUTER_KEY = "XYmWPO9ZYkLqnv3v" +# Discord Bot +DISCORD_TOKEN = "MTQ0Mjg3NjU3NTUzMDg3NzAxMQ.GH7MGP.VdYH4QXmPL-9Zi9zhp-Ot6SmiCxWQOWU3U-1dk" + # Intervals POLL_INTERVAL = 30 HIT_CHECK_INTERVAL = 60 diff --git a/main.py b/main.py index 98dd9c8..b7f2edf 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ # main.py (updated) import discord from discord.ext import commands -from config import ALLOWED_CHANNEL_ID, HIT_CHECK_INTERVAL, REASSIGN_DELAY +from config import ALLOWED_CHANNEL_ID, HIT_CHECK_INTERVAL, REASSIGN_DELAY, DISCORD_TOKEN from cogs.assignments import Assignments from cogs.commands import HitCommands @@ -21,6 +21,7 @@ from pydantic import BaseModel from services.server_state import STATE, Member from services.torn_api import populate_friendly, populate_enemy, start_friendly_status_loop, start_enemy_status_loop +from services.bot_assignment import BotAssignmentManager # ============================================================ # FastAPI Setup @@ -56,6 +57,10 @@ class RemoveAssignmentRequest(BaseModel): class BotControl(BaseModel): action: str # "start" or "stop" +class DiscordMappingRequest(BaseModel): + torn_id: int + discord_id: int + # ================================ # Helper: load JSON file into STATE # ================================ @@ -201,13 +206,98 @@ async def api_clear_assignments(): # ============================= # Bot control endpoint # ============================= +@app.get("/api/bot_status") +async def api_bot_status(): + """Get current bot status""" + active_count = len(assignment_manager.active_targets) if assignment_manager else 0 + return { + "bot_running": STATE.bot_running, + "active_assignments": active_count, + "discord_mappings_count": len(assignment_manager.discord_mapping) if assignment_manager else 0 + } + @app.post("/api/bot_control") async def api_bot_control(req: BotControl): if req.action not in ("start", "stop"): raise HTTPException(status_code=400, detail="invalid action") + STATE.bot_running = (req.action == "start") + + # Start or stop the assignment manager + if assignment_manager: + if req.action == "start": + await assignment_manager.start() + else: + await assignment_manager.stop() + return {"status": "ok", "bot_running": STATE.bot_running} +# ============================= +# Discord Mapping endpoints +# ============================= +@app.get("/api/discord_mappings") +async def get_discord_mappings(): + """Get all Torn ID to Discord ID mappings""" + path = Path("data/discord_mapping.json") + if not path.exists(): + return {"mappings": {}} + + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return {"mappings": data.get("mappings", {})} + +@app.post("/api/discord_mapping") +async def add_discord_mapping(req: DiscordMappingRequest): + """Add or update a Torn ID to Discord ID mapping""" + path = Path("data/discord_mapping.json") + + # Load existing mappings + if path.exists(): + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + else: + data = {"comment": "Map Torn player IDs to Discord user IDs", "mappings": {}} + + # Update mapping + data["mappings"][str(req.torn_id)] = str(req.discord_id) + + # Save back + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + # Reload in assignment manager + if assignment_manager: + assignment_manager.load_discord_mapping() + + return {"status": "ok", "torn_id": req.torn_id, "discord_id": req.discord_id} + +@app.delete("/api/discord_mapping/{torn_id}") +async def remove_discord_mapping(torn_id: int): + """Remove a Discord mapping""" + path = Path("data/discord_mapping.json") + + if not path.exists(): + raise HTTPException(status_code=404, detail="No mappings found") + + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + + # Remove mapping + if str(torn_id) in data.get("mappings", {}): + del data["mappings"][str(torn_id)] + + # Save back + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + # Reload in assignment manager + if assignment_manager: + assignment_manager.load_discord_mapping() + + return {"status": "ok", "torn_id": torn_id} + else: + raise HTTPException(status_code=404, detail="Mapping not found") + # ============================================================ # Reset Groups Endpoint @@ -257,14 +347,27 @@ class HitDispatchBot(commands.Bot): bot = HitDispatchBot(command_prefix="!", intents=intents) +# Initialize bot assignment manager +assignment_manager = None + @bot.event async def on_ready(): - print(f"Logged in as {bot.user.name}") + global assignment_manager + print(f"✓ Discord bot logged in as {bot.user.name} (ID: {bot.user.id})") + print(f"✓ Bot is in {len(bot.guilds)} server(s)") -TOKEN = "YOUR_DISCORD_TOKEN" + # Initialize assignment manager + assignment_manager = BotAssignmentManager(bot) + print("✓ Bot assignment manager initialized") async def start_bot(): - await bot.start(TOKEN) + try: + print("Starting Discord bot...") + await bot.start(DISCORD_TOKEN) + except discord.LoginFailure: + print("ERROR: Invalid Discord token! Please set DISCORD_TOKEN in config.py") + except Exception as e: + print(f"ERROR starting Discord bot: {e}") # ============================================================ # Main Entry Point diff --git a/services/__pycache__/bot_assignment.cpython-311.pyc b/services/__pycache__/bot_assignment.cpython-311.pyc new file mode 100644 index 0000000..f283f3d Binary files /dev/null and b/services/__pycache__/bot_assignment.cpython-311.pyc differ diff --git a/services/bot_assignment.py b/services/bot_assignment.py new file mode 100644 index 0000000..6640896 --- /dev/null +++ b/services/bot_assignment.py @@ -0,0 +1,246 @@ +# services/bot_assignment.py +import asyncio +import json +from pathlib import Path +from typing import Dict, Optional +from datetime import datetime +from services.server_state import STATE + +class BotAssignmentManager: + def __init__(self, bot): + self.bot = bot + self.discord_mapping: Dict[int, int] = {} # torn_id -> discord_id + self.active_targets: Dict[str, Dict] = {} # key: "group_id:enemy_id" -> assignment data + self.running = False + self.task = None + + # Load Discord mapping + self.load_discord_mapping() + + def load_discord_mapping(self): + """Load Torn ID to Discord ID mapping from JSON file""" + path = Path("data/discord_mapping.json") + if not path.exists(): + print("No discord_mapping.json found") + return + + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + # Convert string keys to int + self.discord_mapping = {int(k): int(v) for k, v in data.get("mappings", {}).items()} + print(f"Loaded {len(self.discord_mapping)} Discord mappings") + except Exception as e: + print(f"Error loading discord mapping: {e}") + + def get_discord_id(self, torn_id: int) -> Optional[int]: + """Get Discord user ID for a Torn player ID""" + return self.discord_mapping.get(torn_id) + + async def start(self): + """Start the bot assignment loop""" + if self.running: + print("⚠ Bot assignment already running") + return + + self.running = True + self.task = asyncio.create_task(self.assignment_loop()) + print("✓ Bot assignment loop started") + print(f"✓ Loaded {len(self.discord_mapping)} Discord ID mappings") + + async def stop(self): + """Stop the bot assignment loop""" + if not self.running: + return + + self.running = False + if self.task: + self.task.cancel() + try: + await self.task + except asyncio.CancelledError: + pass + print("Bot assignment stopped") + + def get_next_friendly_in_group(self, group_id: str, friendly_ids: list) -> Optional[int]: + """ + Get the next friendly in the group who should receive a target. + Prioritizes members with fewer hits. + """ + if not friendly_ids: + return None + + # Get hit counts for all friendlies in this group + friendly_hits = [] + for fid in friendly_ids: + if fid in STATE.friendly: + hits = STATE.friendly[fid].hits + friendly_hits.append((fid, hits)) + + if not friendly_hits: + return None + + # Sort by hit count (ascending) - members with fewer hits first + friendly_hits.sort(key=lambda x: x[1]) + + # Return the friendly with the fewest hits + return friendly_hits[0][0] + + def get_next_enemy_in_group(self, group_id: str, enemy_ids: list) -> Optional[int]: + """ + Get the next enemy in the group who needs to be assigned. + Returns None if all enemies are already assigned. + """ + for eid in enemy_ids: + key = f"{group_id}:{eid}" + # If enemy is not currently assigned, return it + if key not in self.active_targets: + return eid + return None + + async def assignment_loop(self): + """Main loop that assigns targets and monitors status""" + await self.bot.wait_until_ready() + print("✓ Bot is ready, assignment loop running") + + first_run = True + while self.running: + try: + # Check if bot is enabled via STATE + if not STATE.bot_running: + if first_run: + print("⏸ Bot paused - waiting for Start Bot button to be clicked") + first_run = False + await asyncio.sleep(5) + continue + + if first_run: + print("▶ Bot activated - processing assignments") + first_run = False + + # Process each group + has_assignments = False + async with STATE.lock: + for group_id, assignments in STATE.groups.items(): + friendly_ids = assignments.get("friendly", []) + enemy_ids = assignments.get("enemy", []) + + if friendly_ids and enemy_ids: + has_assignments = True + + if not friendly_ids or not enemy_ids: + continue + + # Try to assign any unassigned enemies + enemy_id = self.get_next_enemy_in_group(group_id, enemy_ids) + if enemy_id: + friendly_id = self.get_next_friendly_in_group(group_id, friendly_ids) + if friendly_id: + await self.assign_target(group_id, friendly_id, enemy_id) + + if not has_assignments and STATE.bot_running: + print("⚠ No group assignments found - drag members into groups first!") + + # Monitor active targets for status changes or timeouts + await self.monitor_active_targets() + + # Sleep before next iteration + await asyncio.sleep(5) + + except Exception as e: + print(f"❌ Error in assignment loop: {e}") + import traceback + traceback.print_exc() + await asyncio.sleep(5) + + async def assign_target(self, group_id: str, friendly_id: int, enemy_id: int): + """Assign an enemy target to a friendly player""" + # Get member data + friendly = STATE.friendly.get(friendly_id) + enemy = STATE.enemy.get(enemy_id) + + if not friendly or not enemy: + print(f"Cannot assign: friendly {friendly_id} or enemy {enemy_id} not found") + return + + # Get Discord user + discord_id = self.get_discord_id(friendly_id) + if not discord_id: + print(f"No Discord mapping for Torn ID {friendly_id}") + return + + discord_user = await self.bot.fetch_user(discord_id) + if not discord_user: + print(f"Discord user {discord_id} not found") + return + + # Record assignment + key = f"{group_id}:{enemy_id}" + self.active_targets[key] = { + "group_id": group_id, + "friendly_id": friendly_id, + "enemy_id": enemy_id, + "discord_id": discord_id, + "assigned_at": datetime.now(), + "reminded": False + } + + # Send Discord message + enemy_link = f"https://www.torn.com/profiles.php?XID={enemy_id}" + message = f"🎯 **New target for {discord_user.mention}!**\n\nAttack **{enemy.name}** (Level {enemy.level})\n{enemy_link}\n\n⏰ You have 30 seconds!" + + try: + await discord_user.send(message) + print(f"Assigned {enemy.name} to {friendly.name} (Discord: {discord_user.name})") + except Exception as e: + print(f"Failed to send Discord message to {discord_user.name}: {e}") + + async def monitor_active_targets(self): + """Monitor active targets for status changes or timeouts""" + now = datetime.now() + to_reassign = [] + + for key, data in list(self.active_targets.items()): + elapsed = (now - data["assigned_at"]).total_seconds() + + # Check enemy status + enemy_id = data["enemy_id"] + enemy = STATE.enemy.get(enemy_id) + + if enemy and "hospital" in enemy.status.lower(): + # Enemy is hospitalized - success! + friendly_id = data["friendly_id"] + if friendly_id in STATE.friendly: + # Increment hit count + STATE.friendly[friendly_id].hits += 1 + print(f"✓ {STATE.friendly[friendly_id].name} successfully hospitalized {enemy.name}") + + # Remove from active targets + del self.active_targets[key] + continue + + # Send reminder at 15 seconds + if elapsed >= 15 and not data["reminded"]: + discord_id = data["discord_id"] + try: + discord_user = await self.bot.fetch_user(discord_id) + await discord_user.send(f"⏰ **Reminder:** Target {enemy.name} - 15 seconds left!") + data["reminded"] = True + except: + pass + + # Reassign after 30 seconds + if elapsed >= 30: + to_reassign.append((data["group_id"], enemy_id)) + del self.active_targets[key] + + # Reassign targets that timed out + for group_id, enemy_id in to_reassign: + friendly_ids = STATE.groups[group_id].get("friendly", []) + friendly_id = self.get_next_friendly_in_group(group_id, friendly_ids) + if friendly_id: + print(f"⚠ Reassigning enemy {enemy_id} (timeout)") + await self.assign_target(group_id, friendly_id, enemy_id) + +# Global instance (will be initialized with bot in main.py) +assignment_manager: Optional[BotAssignmentManager] = None diff --git a/static/dashboard.js b/static/dashboard.js index 09ca1e4..c76c0cf 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -658,6 +658,39 @@ async function toggleEnemyStatus() { btn.textContent = "Stop Refresh"; } +// --------------------------- +// Bot control (start/stop) +// --------------------------- +async function toggleBotControl() { + const btn = document.getElementById("bot-control-btn"); + const isRunning = btn.dataset.running === "true"; + const action = isRunning ? "stop" : "start"; + + try { + const res = await fetch("/api/bot_control", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }) + }); + + if (!res.ok) { + console.error("Bot control failed:", res.status); + return; + } + + const data = await res.json(); + + // Update button state + btn.dataset.running = data.bot_running ? "true" : "false"; + btn.textContent = data.bot_running ? "Stop Bot" : "Start Bot"; + btn.style.backgroundColor = data.bot_running ? "#ff4444" : "#4CAF50"; + + console.log(`Bot ${data.bot_running ? "started" : "stopped"}`); + } catch (err) { + console.error("toggleBotControl error:", err); + } +} + // --------------------------- // Reset groups (server-side) // --------------------------- @@ -682,6 +715,10 @@ function wireUp() { if (enemyBtn) enemyBtn.addEventListener("click", populateEnemy); document.getElementById("friendly-status-btn").addEventListener("click", toggleFriendlyStatus); document.getElementById("enemy-status-btn").addEventListener("click", toggleEnemyStatus); + + const botBtn = document.getElementById("bot-control-btn"); + if (botBtn) botBtn.addEventListener("click", toggleBotControl); + const resetBtn = document.getElementById("reset-groups-btn"); if (resetBtn) resetBtn.addEventListener("click", resetGroups); diff --git a/static/styles.css b/static/styles.css index 6237fc8..33bf8a9 100644 --- a/static/styles.css +++ b/static/styles.css @@ -198,6 +198,42 @@ button { } button:hover { background-color: #3399ff; } +/* Bot control button */ +.bot-btn { + background-color: #4CAF50; + color: white; + padding: 0.6rem 1rem; + font-size: 1rem; + font-weight: bold; + border-radius: 8px; + transition: background-color 0.3s; +} + +.bot-btn:hover { + opacity: 0.9; +} + +.bot-btn[data-running="true"] { + background-color: #ff4444; +} + +/* Reset button */ +.reset-btn { + background-color: #ff8800; + color: white; +} + +.reset-btn:hover { + background-color: #ff6600; +} + +/* Top controls layout */ +.top-controls { + display: flex; + gap: 1rem; + align-items: center; +} + /* scrollbar niceties for drop zones and lists */ .member-list::-webkit-scrollbar, .drop-zone::-webkit-scrollbar { width: 8px; height: 8px; } .member-list::-webkit-scrollbar-thumb, .drop-zone::-webkit-scrollbar-thumb { background: #66ccff; border-radius: 4px; } diff --git a/templates/dashboard.html b/templates/dashboard.html index ca53085..a2212bd 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -13,6 +13,7 @@