Files
faction_war_dispatch_bot/main.py
2025-11-25 14:22:36 -05:00

325 lines
8.7 KiB
Python

import discord
import asyncio
import aiohttp
from discord.ext import commands
import re
# ==============================
# CONFIGURATION
# ==============================
TORN_API_KEY = "9VLK0Wte1BwXOheB"
ENEMY_FACTION_ID = 52935
YOUR_FACTION_ID = 654321
ALLOWED_CHANNEL_ID = 1442876328536707316
FFSCOUTER_KEY = "XYmWPO9ZYkLqnv3v"
POLL_INTERVAL = 30
HIT_CHECK_INTERVAL = 60
REASSIGN_DELAY = 120
intents = discord.Intents.default()
intents.message_content = True
# ==============================
# STATE STORAGE
# ==============================
enrolled_attackers = []
enemy_queue = []
active_assignments = {}
round_robin_index = 0
# ==============================
# BOT SUBCLASS
# ==============================
class HitDispatchBot(commands.Bot):
async def setup_hook(self):
# Start background loops
self.bg_tasks = []
self.bg_tasks.append(asyncio.create_task(update_enemy_queue_loop()))
self.bg_tasks.append(asyncio.create_task(monitor_assignments_loop()))
async def cog_check(self, ctx):
return ctx.channel.id == ALLOWED_CHANNEL_ID
# Create bot now (ONE bot only)
bot = HitDispatchBot(command_prefix="!", intents=intents)
async def fetch_ffscouter_stats(session: aiohttp.ClientSession, torn_id: int):
"""
Calls FFScouter and returns predicted battle stats.
Uses an existing aiohttp session (caller must provide).
"""
url = f"https://ffscouter.com/api/v1/get-stats?key={FFSCOUTER_KEY}&targets={torn_id}"
#print(url)
async with session.get(url) as resp:
if resp.status != 200:
print(f"FFScouter Error for {torn_id}:", resp.status)
return None
data = await resp.json()
# FFScouter v1 returns: {"code":200,"message":"OK","data": {"<id>": {...}}}
if "data" not in data:
return None
inner = data["data"]
return inner.get(str(torn_id))
async def fetch_enemy_faction():
"""
Pulls faction members from Torn (selections=members),
then fetches FFScouter stats in a single batch request.
Returns a list of enemies with name, id, level, and estimated BS.
"""
url = (
f"https://api.torn.com/v2/faction/{ENEMY_FACTION_ID}"
f"?selections=members&key={TORN_API_KEY}"
)
enemies = []
async with aiohttp.ClientSession() as session:
# --- Fetch faction members ---
async with session.get(url) as resp:
if resp.status != 200:
print("Torn faction fetch error:", resp.status)
return enemies
data = await resp.json()
members_list = data.get("members", [])
if not members_list:
return enemies
# --- Build comma-separated list of IDs ---
member_ids = [
str(info.get("player_id", info.get("id", 0)))
for info in members_list
if info.get("status", {}).get("state", "Unknown") in ("Okay", "Idle")
]
if not member_ids:
return enemies
ids_str = ",".join(member_ids)
# --- Single FFScouter batch request ---
ff_url = f"https://ffscouter.com/api/v1/get-stats?key={FFSCOUTER_KEY}&targets={ids_str}"
async with session.get(ff_url) as resp:
if resp.status != 200:
print("FFScouter batch error:", resp.status)
ff_data_list = []
else:
ff_data_list = await resp.json()
# --- Map FFScouter data by player_id for quick lookup ---
ff_map = {str(d["player_id"]): d for d in ff_data_list}
# --- Build final enemies list ---
for info in members_list:
pid = str(info.get("player_id", info.get("id", 0)))
state = info.get("status", {}).get("state", "Unknown")
if state not in ("Okay", "Idle"):
continue
name = info.get("name", "Unknown")
level = int(info.get("level", 0))
est = ff_map.get(pid, {}).get("bs_estimate_human", "?")
enemies.append({
"id": int(pid),
"name": name,
"level": level,
"estimate": est
})
return enemies
async def update_enemy_queue_loop():
global enemy_queue
await bot.wait_until_ready()
while not bot.is_closed():
print("Refreshing enemy list...")
enemy_queue = await fetch_enemy_faction()
print(f"Enemy queue updated: {len(enemy_queue)} valid targets.")
await asyncio.sleep(POLL_INTERVAL)
# ==============================
# ASSIGNMENT SYSTEM
# ==============================
def get_next_attacker():
global round_robin_index
if not enrolled_attackers:
return None
attacker = enrolled_attackers[round_robin_index]
round_robin_index = (round_robin_index + 1) % len(enrolled_attackers)
return attacker
async def assign_next_target():
if not enemy_queue:
return None, None
# Pop the weakest enemy (already sorted by score_value)
enemy = enemy_queue.pop(0)
attacker = get_next_attacker()
if attacker is None:
return None, None
active_assignments[enemy["id"]] = {
"enemy": enemy,
"attacker": attacker,
"time_assigned": asyncio.get_event_loop().time()
}
return enemy, attacker
async def monitor_assignments_loop():
await bot.wait_until_ready()
while not bot.is_closed():
now = asyncio.get_event_loop().time()
reassign_list = []
for enemy_id, data in list(active_assignments.items()):
elapsed = now - data["time_assigned"]
if elapsed >= HIT_CHECK_INTERVAL and elapsed < HIT_CHECK_INTERVAL + 5:
attacker_user = bot.get_user(data["attacker"])
if attacker_user:
try:
await attacker_user.send(
f"Reminder: You were assigned **{data['enemy']['name']}** and they are not down yet!"
)
except:
pass
if elapsed >= REASSIGN_DELAY:
reassign_list.append(enemy_id)
for enemy_id in reassign_list:
info = active_assignments.pop(enemy_id)
await reassign_target(info["enemy"])
await asyncio.sleep(5)
async def reassign_target(enemy):
attacker = get_next_attacker()
if attacker is None:
return
active_assignments[enemy["id"]] = {
"enemy": enemy,
"attacker": attacker,
"time_assigned": asyncio.get_event_loop().time()
}
attacker_user = bot.get_user(attacker)
if attacker_user:
try:
await attacker_user.send(
f"Target reassigned to you: **{enemy['name']}**!"
)
except:
pass
# ==============================
# COMMANDS
# ==============================
@bot.command()
async def enroll(ctx):
user_id = ctx.author.id
if user_id in enrolled_attackers:
await ctx.send("You are already enrolled.")
return
enrolled_attackers.append(user_id)
await ctx.send(f"{ctx.author.mention} has been enrolled in the hit rotation!")
@bot.command()
async def drop(ctx):
user_id = ctx.author.id
if user_id not in enrolled_attackers:
await ctx.send("You are not enrolled.")
return
enrolled_attackers.remove(user_id)
await ctx.send(f"{ctx.author.mention} has been removed from the rotation.")
@bot.command()
async def next(ctx):
enemy, attacker = await assign_next_target()
if enemy is None:
await ctx.send("No targets available or no attackers enrolled.")
return
attacker_user = bot.get_user(attacker)
await ctx.send(f"Assigned **{enemy['name']}** → <@{attacker}>")
if attacker_user:
try:
await attacker_user.send(
f"Hit assignment: **{enemy['name']}**"
)
except:
pass
@bot.command()
async def stats(ctx):
enemies = await fetch_enemy_faction()
if not enemies:
await ctx.send("No active members found.")
return
lines = [
f"**{e['name']}** (ID:{e['id']}) | Lv {e['level']} | Estimated BS: {e['estimate']}"
for e in enemies
]
# Discord chunking
chunk = ""
for line in lines:
if len(chunk) + len(line) > 1900:
await ctx.send(chunk)
chunk = ""
chunk += line + "\n"
if chunk:
await ctx.send(chunk)
# ==============================
# BOT READY
# ==============================
@bot.event
async def on_ready():
print(f"Logged in as {bot.user.name}")
# ==============================
# RUN BOT
# ==============================
bot.run("MTQ0Mjg3NjU3NTUzMDg3NzAxMQ.GNuHPr.UreuYD1B7YYjfsbfRcEbhFyjyqvhQDepRCN4kk")