diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..a6db65d Binary files /dev/null and b/__pycache__/config.cpython-313.pyc differ diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..ad16d13 Binary files /dev/null and b/__pycache__/main.cpython-313.pyc differ diff --git a/cogs/__pycache__/assignments.cpython-313.pyc b/cogs/__pycache__/assignments.cpython-313.pyc new file mode 100644 index 0000000..aa03944 Binary files /dev/null and b/cogs/__pycache__/assignments.cpython-313.pyc differ diff --git a/cogs/__pycache__/commands.cpython-313.pyc b/cogs/__pycache__/commands.cpython-313.pyc new file mode 100644 index 0000000..006efa9 Binary files /dev/null and b/cogs/__pycache__/commands.cpython-313.pyc differ diff --git a/cogs/assignments.py b/cogs/assignments.py new file mode 100644 index 0000000..4bfe2c2 --- /dev/null +++ b/cogs/assignments.py @@ -0,0 +1,64 @@ +import asyncio +from discord.ext import commands + +class Assignments(commands.Cog): + def __init__(self, bot, enemy_queue, active_assignments, enrolled_attackers, hit_check, reassign_delay): + self.bot = bot + self.enemy_queue = enemy_queue + self.active_assignments = active_assignments + self.enrolled_attackers = enrolled_attackers + self.HIT_CHECK_INTERVAL = hit_check + self.REASSIGN_DELAY = reassign_delay + + # Start background task + bot.loop.create_task(self.monitor_assignments_loop()) + + def get_next_attacker(self): + if not self.enrolled_attackers: + return None + attacker = self.enrolled_attackers[0] + self.enrolled_attackers.append(self.enrolled_attackers.pop(0)) # round-robin + return attacker + + async def monitor_assignments_loop(self): + await self.bot.wait_until_ready() + while not self.bot.is_closed(): + now = asyncio.get_event_loop().time() + reassign_list = [] + for enemy_id, data in list(self.active_assignments.items()): + elapsed = now - data["time_assigned"] + if elapsed >= self.HIT_CHECK_INTERVAL and elapsed < self.HIT_CHECK_INTERVAL + 5: + attacker_user = self.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 >= self.REASSIGN_DELAY: + reassign_list.append(enemy_id) + + for enemy_id in reassign_list: + info = self.active_assignments.pop(enemy_id) + await self.reassign_target(info["enemy"]) + + await asyncio.sleep(5) + + async def reassign_target(self, enemy): + attacker = self.get_next_attacker() + if attacker is None: + return + + self.active_assignments[enemy["id"]] = { + "enemy": enemy, + "attacker": attacker, + "time_assigned": asyncio.get_event_loop().time() + } + + attacker_user = self.bot.get_user(attacker) + if attacker_user: + try: + await attacker_user.send(f"Target reassigned to you: **{enemy['name']}**!") + except: + pass diff --git a/cogs/commands.py b/cogs/commands.py new file mode 100644 index 0000000..1da96a5 --- /dev/null +++ b/cogs/commands.py @@ -0,0 +1,54 @@ +from discord.ext import commands +from services.torn_api import fetch_enemy_members +from services.ffscouter import fetch_batch_stats + +class HitCommands(commands.Cog): + def __init__(self, bot, enrolled_attackers, enemy_queue): + self.bot = bot + self.enrolled_attackers = enrolled_attackers + self.enemy_queue = enemy_queue + + @commands.command() + async def enroll(self, ctx): + user_id = ctx.author.id + if user_id in self.enrolled_attackers: + await ctx.send("You are already enrolled.") + return + self.enrolled_attackers.append(user_id) + await ctx.send(f"{ctx.author.mention} has been enrolled in the hit rotation!") + + @commands.command() + async def drop(self, ctx): + user_id = ctx.author.id + if user_id not in self.enrolled_attackers: + await ctx.send("You are not enrolled.") + return + self.enrolled_attackers.remove(user_id) + await ctx.send(f"{ctx.author.mention} has been removed from the rotation.") + + @commands.command() + async def stats(self, ctx): + members = await fetch_enemy_members() + if not members: + await ctx.send("No active members found.") + return + + ids = [m["id"] for m in members if m.get("status", {}).get("state") in ("Okay", "Idle")] + ff_map = await fetch_batch_stats(ids) + + lines = [] + for m in members: + pid = str(m["id"]) + est = ff_map.get(pid, {}).get("bs_estimate_human", "?") + if m.get("status", {}).get("state") not in ("Okay", "Idle"): + continue + lines.append(f"**{m['name']}** (ID:{pid}) | Lv {m['level']} | Estimated BS: {est}") + + 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) diff --git a/config.py b/config.py new file mode 100644 index 0000000..67a5122 --- /dev/null +++ b/config.py @@ -0,0 +1,13 @@ +# Torn API +TORN_API_KEY = "9VLK0Wte1BwXOheB" +ENEMY_FACTION_ID = 52935 +YOUR_FACTION_ID = 654321 +ALLOWED_CHANNEL_ID = 1442876328536707316 + +# FFScouter API +FFSCOUTER_KEY = "XYmWPO9ZYkLqnv3v" + +# Intervals +POLL_INTERVAL = 30 +HIT_CHECK_INTERVAL = 60 +REASSIGN_DELAY = 120 diff --git a/main.py b/main.py index 6089558..6b504f4 100644 --- a/main.py +++ b/main.py @@ -1,324 +1,46 @@ 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 +from discord.ext import commands +from config import ALLOWED_CHANNEL_ID, HIT_CHECK_INTERVAL, REASSIGN_DELAY +from cogs.assignments import Assignments +from cogs.commands import HitCommands intents = discord.Intents.default() intents.message_content = True -# ============================== -# STATE STORAGE -# ============================== - +# Global state 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())) + # Load cogs with injected state + await self.add_cog( + Assignments( + self, + enemy_queue=enemy_queue, + active_assignments=active_assignments, + enrolled_attackers=enrolled_attackers, + hit_check=HIT_CHECK_INTERVAL, + reassign_delay=REASSIGN_DELAY + ) + ) + await self.add_cog( + HitCommands( + self, + enrolled_attackers=enrolled_attackers, + enemy_queue=enemy_queue + ) + ) 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": {"": {...}}} - 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") diff --git a/services/__pycache__/ffscouter.cpython-313.pyc b/services/__pycache__/ffscouter.cpython-313.pyc new file mode 100644 index 0000000..27ff874 Binary files /dev/null and b/services/__pycache__/ffscouter.cpython-313.pyc differ diff --git a/services/__pycache__/torn_api.cpython-313.pyc b/services/__pycache__/torn_api.cpython-313.pyc new file mode 100644 index 0000000..c0256b1 Binary files /dev/null and b/services/__pycache__/torn_api.cpython-313.pyc differ diff --git a/services/ffscouter.py b/services/ffscouter.py new file mode 100644 index 0000000..866949d --- /dev/null +++ b/services/ffscouter.py @@ -0,0 +1,22 @@ +import aiohttp +from config import FFSCOUTER_KEY + +async def fetch_batch_stats(ids: list[int]): + """ + Fetches predicted stats for a list of Torn IDs in a single FFScouter request. + Returns dict keyed by player_id. + """ + if not ids: + return {} + + ids_str = ",".join(map(str, ids)) + url = f"https://ffscouter.com/api/v1/get-stats?key={FFSCOUTER_KEY}&targets={ids_str}" + + async with aiohttp.ClientSession() as session: + async with session.get(url) as resp: + if resp.status != 200: + print("FFScouter batch error:", resp.status) + return {} + data = await resp.json() + + return {str(d["player_id"]): d for d in data} diff --git a/services/torn_api.py b/services/torn_api.py new file mode 100644 index 0000000..7ce297c --- /dev/null +++ b/services/torn_api.py @@ -0,0 +1,12 @@ +import aiohttp +from config import TORN_API_KEY, ENEMY_FACTION_ID + +async def fetch_enemy_members(): + url = f"https://api.torn.com/v2/faction/{ENEMY_FACTION_ID}?selections=members&key={TORN_API_KEY}" + async with aiohttp.ClientSession() as session: + async with session.get(url) as resp: + if resp.status != 200: + print("Torn faction fetch error:", resp.status) + return [] + data = await resp.json() + return data.get("members", [])