Basic Discord functionality with assignments

This commit is contained in:
2026-01-25 18:56:11 -05:00
parent ee7114d25d
commit 055abd501f
9 changed files with 431 additions and 5 deletions

View File

@@ -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
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

Binary file not shown.

View File

@@ -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

111
main.py
View File

@@ -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

Binary file not shown.

246
services/bot_assignment.py Normal file
View File

@@ -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

View File

@@ -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);

View File

@@ -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; }

View File

@@ -13,6 +13,7 @@
<h1>War Dashboard</h1>
<div class="top-controls">
<button id="bot-control-btn" class="bot-btn" data-running="false">Start Bot</button>
<button id="reset-groups-btn" class="reset-btn">Reset Groups</button>
<div class="interval-box">