#!/usr/bin/env python3 # torn_hit_dispatch.py # Requirements: discord.py (2.x), aiohttp # pip install -U "discord.py" aiohttp import asyncio import json import logging from typing import Dict, List, Optional import aiohttp import discord from discord.ext import commands, tasks # --------------------------- # Configuration (edit me) # --------------------------- DISCORD_TOKEN = "YOUR_DISCORD_BOT_TOKEN" TORN_API_KEY = "YOUR_TORN_API_KEY" FACTION_ID = 123456 # the enemy faction id to poll for enemies DATA_FILE = "dispatch_state.json" ASSIGNMENT_TIMEOUT = 60 # seconds to wait before pinging assigned player REASSIGNMENT_TIMEOUT = 60 # additional seconds to wait before reassigning MAX_REASSIGN_ATTEMPTS = 5 # stop after this many reassignments to avoid loops PING_ROLE_ID = None # optional: role id to mention when broadcasting assignments # --------------------------- # Logging # --------------------------- logging.basicConfig(level=logging.INFO) log = logging.getLogger("torn_dispatch") # --------------------------- # Torn API helper (implement your endpoints here) # --------------------------- class TornAPI: """ A small wrapper to communicate with Torn API. Replace the TODOs in the methods with the exact endpoints & logic you want. The rest of the bot depends only on these functions. """ def __init__(self, api_key: str): self.api_key = api_key self.base = "https://api.torn.com" # change if you have other host self.session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) async def close(self): await self.session.close() async def get_enemy_members(self, faction_id: int) -> List[Dict]: """ Return a list of enemy members as dicts with at least: - 'player_id' (int) - 'name' (str) - 'status' keys to determine hospital/abroad (could be 'hospital' bool or status string) Implement this using the Torn API endpoint that returns faction members. """ # TODO: Replace with actual Torn API call for faction roster or selection # Example (pseudo): # url = f"{self.base}/faction/{faction_id}?selections=members&key={self.api_key}" # resp = await self.session.get(url) # data = await resp.json() # return data["members"] # adapt to real response shape raise NotImplementedError("Implement get_enemy_members() with Torn API") async def is_player_available(self, player_id: int) -> bool: """ Check if a given player (enemy) is hittable: - not in hospital - not abroad/out of country Implement using Torn API player endpoint. """ # TODO: implement actual check using Torn API raise NotImplementedError("Implement is_player_available() with Torn API") async def was_player_hit_recently(self, enemy_player_id: int, since_seconds: int = 120) -> bool: """ Check if the enemy player has been hit in the recent window. You can check API endpoints for attacks or health changes; returns True if recently hit. """ # TODO: implement by checking attack history or health changes via Torn API raise NotImplementedError("Implement was_player_hit_recently() with Torn API") # --------------------------- # --------------------------- # Bot state classes # --------------------------- class Participant: def __init__(self, discord_id: int, display_name: str): self.discord_id = discord_id self.display_name = display_name def to_dict(self): return {"discord_id": self.discord_id, "display_name": self.display_name} @staticmethod def from_dict(d): return Participant(d["discord_id"], d.get("display_name", "Unknown")) class DispatchState: def __init__(self): # participants enrolled (round-robin) self.participants: List[Participant] = [] # queue of enemy player ids to assign (list of dicts with id + name) self.enemy_list: List[Dict] = [] # current assignments: enemy_id -> assignment record self.assignments: Dict[int, Dict] = {} # round-robin pointer self.rr_index = 0 def to_dict(self): return { "participants": [p.to_dict() for p in self.participants], "enemy_list": self.enemy_list, "assignments": self.assignments, "rr_index": self.rr_index, } @staticmethod def from_dict(d): s = DispatchState() s.participants = [Participant.from_dict(x) for x in d.get("participants", [])] s.enemy_list = d.get("enemy_list", []) s.assignments = d.get("assignments", {}) s.rr_index = d.get("rr_index", 0) return s # --------------------------- # Discord bot # --------------------------- intents = discord.Intents.default() intents.message_content = True intents.members = True bot = commands.Bot(command_prefix="!", intents=intents) torn = TornAPI(TORN_API_KEY) state = DispatchState() monitor_tasks: Dict[int, asyncio.Task] = {} # enemy_id -> monitor task state_lock = asyncio.Lock() # protect shared state writes # --------------------------- # Persistence helpers # --------------------------- def load_state(): try: with open(DATA_FILE, "r", encoding="utf-8") as f: obj = json.load(f) return DispatchState.from_dict(obj) except FileNotFoundError: return DispatchState() except Exception as e: log.exception("Failed to load state: %s", e) return DispatchState() def save_state(): try: with open(DATA_FILE, "w", encoding="utf-8") as f: json.dump(state.to_dict(), f, indent=2) except Exception: log.exception("Failed to save state") # --------------------------- # Utility functions # --------------------------- def next_participant() -> Optional[Participant]: if not state.participants: return None # round-robin selection p = state.participants[state.rr_index % len(state.participants)] state.rr_index = (state.rr_index + 1) % max(1, len(state.participants)) return p async def mention_user(ctx_or_member, discord_id: int) -> str: # helper to build mention text (works in-channel and in messages) return f"<@{discord_id}>" async def assign_enemy_to_next(enemy: Dict, reason: str = "initial") -> Optional[Dict]: """ Assigns the enemy to the next available participant and creates an assignment record. Returns the assignment record or None if no participants. """ async with state_lock: p = next_participant() if p is None: return None enemy_id = int(enemy["player_id"]) record = { "enemy": enemy, "assigned_to": p.to_dict(), "assigned_at": asyncio.get_event_loop().time(), "attempts": 0, "last_pinged": None, "reason": reason, } state.assignments[enemy_id] = record save_state() return record async def unassign_enemy(enemy_id: int): async with state_lock: if enemy_id in state.assignments: del state.assignments[enemy_id] save_state() # --------------------------- # Monitor & assignment loop # --------------------------- async def monitor_assignment(guild: discord.Guild, channel: discord.TextChannel, enemy_id: int): """ Monitors a single assignment: wait ASSIGNMENT_TIMEOUT, check if hit, ping if not. If still not hit, reassign after REASSIGNMENT_TIMEOUT. Repeat until MAX_REASSIGN_ATTEMPTS. """ try: for attempt in range(1, MAX_REASSIGN_ATTEMPTS + 1): async with state_lock: record = state.assignments.get(enemy_id) if not record: # assignment removed or resolved return assigned_to = record["assigned_to"] enemy = record["enemy"] # Wait first interval await asyncio.sleep(ASSIGNMENT_TIMEOUT) # check if enemy was hit in the meantime try: hit = await torn.was_player_hit_recently(enemy_id, since_seconds=(ASSIGNMENT_TIMEOUT + 5)) except NotImplementedError: # default fallback: assume not hit (so the bot will ping participants) hit = False except Exception: log.exception("Error checking hit status") hit = False if hit: # mark resolved await channel.send(f"Enemy **{enemy.get('name','?')}** was hit — resolving assignment.") await unassign_enemy(enemy_id) return # ping the assigned participant mention = f"<@{assigned_to['discord_id']}>" role_mention = f"<@&{PING_ROLE_ID}>" if PING_ROLE_ID else "" await channel.send(f"{role_mention} {mention} — you were assigned to hit **{enemy.get('name','?')}** (ID {enemy_id}). Attempt {attempt}/{MAX_REASSIGN_ATTEMPTS}.") # update attempt count and last_pinged async with state_lock: rec = state.assignments.get(enemy_id) if rec: rec["attempts"] = attempt rec["last_pinged"] = asyncio.get_event_loop().time() save_state() # wait second interval await asyncio.sleep(REASSIGNMENT_TIMEOUT) # check again if hit try: hit2 = await torn.was_player_hit_recently(enemy_id, since_seconds=(REASSIGNMENT_TIMEOUT + 5)) except Exception: log.exception("Error checking hit status (2)") hit2 = False if hit2: await channel.send(f"Enemy **{enemy.get('name','?')}** was hit — resolving assignment.") await unassign_enemy(enemy_id) return # if not hit, reassign to next participant async with state_lock: # pick next participant (advance rr pointer) next_p = next_participant() if not next_p: await channel.send(f"No participants available to reassign **{enemy.get('name','?')}**.") # keep the existing assignment but increment attempts rec = state.assignments.get(enemy_id) if rec: rec["attempts"] += 1 save_state() continue # update assignment record rec = state.assignments.get(enemy_id) if rec: rec["assigned_to"] = next_p.to_dict() rec["assigned_at"] = asyncio.get_event_loop().time() rec["reason"] = f"reassign_attempt_{attempt}" save_state() await channel.send(f"Reassigned **{enemy.get('name','?')}** to {next_p.display_name} (attempt {attempt}).") # if reached here, exhausted attempts await channel.send(f"Exhausted attempts assigning **{state.assignments.get(enemy_id, {}).get('enemy', {}).get('name','?')}** — removing from assignments.") await unassign_enemy(enemy_id) except asyncio.CancelledError: # task was cancelled (probably due to stop) log.info("Monitor for enemy %s cancelled", enemy_id) return except Exception: log.exception("Error in monitor_assignment") async def refresh_enemy_list(): """ Pull the latest enemy list from Torn and populate state.enemy_list with enemies that are hittable. """ try: raw = await torn.get_enemy_members(FACTION_ID) available = [] for r in raw: pid = int(r["player_id"]) # check availability try: ok = await torn.is_player_available(pid) except NotImplementedError: # if not implemented, default to including them ok = True if ok: available.append({"player_id": pid, "name": r.get("name", "Unknown")}) async with state_lock: state.enemy_list = available save_state() return available except Exception: log.exception("Failed to refresh enemy list") return [] async def assign_all_enemies(channel: discord.TextChannel): """ Iterate enemy_list and create assignments for unassigned enemies. """ async with state_lock: enemy_copy = list(state.enemy_list) for enemy in enemy_copy: enemy_id = int(enemy["player_id"]) async with state_lock: if enemy_id in state.assignments: continue rec = await assign_enemy_to_next(enemy, reason="batch_assign") if rec: # start monitor task for this enemy task = asyncio.create_task(monitor_assignment(channel.guild, channel, enemy_id)) monitor_tasks[enemy_id] = task await channel.send(f"Assigned **{enemy.get('name','?')}** to {rec['assigned_to']['display_name']}.") else: await channel.send(f"No participants to assign **{enemy.get('name','?')}** — skipping.") # --------------------------- # Bot commands # --------------------------- @bot.event async def on_ready(): log.info(f"Logged in as {bot.user} (id: {bot.user.id})") global state state = load_state() log.info("State loaded: %d participants, %d enemies, %d assignments", len(state.participants), len(state.enemy_list), len(state.assignments)) @bot.command(name="enroll") async def enroll(ctx): """Enroll yourself into the round-robin pool. Usage: !enroll""" async with state_lock: existing = [p for p in state.participants if p.discord_id == ctx.author.id] if existing: await ctx.send("You're already enrolled.") return p = Participant(ctx.author.id, ctx.author.display_name) state.participants.append(p) save_state() await ctx.send(f"Enrolled {ctx.author.display_name} in the hit queue.") @bot.command(name="leave") async def leave(ctx): """Leave the round-robin pool. Usage: !leave""" async with state_lock: before = len(state.participants) state.participants = [p for p in state.participants if p.discord_id != ctx.author.id] if len(state.participants) < before: save_state() await ctx.send("You have been removed from the pool.") else: await ctx.send("You were not enrolled.") @bot.command(name="list_participants") async def list_participants(ctx): """List enrolled participants.""" if not state.participants: await ctx.send("No participants enrolled.") return msg = "Enrolled participants:\n" + "\n".join(f"- {p.display_name} (discord:{p.discord_id})" for p in state.participants) await ctx.send(msg) @bot.command(name="refresh_enemies") async def cmd_refresh_enemies(ctx): """Refresh enemy list from Torn and show the count. Usage: !refresh_enemies""" enemies = await refresh_enemy_list() await ctx.send(f"Refreshed enemy list — {len(enemies)} hittable enemies loaded.") @bot.command(name="start_round") async def start_round(ctx): """Assign enemies to participants and start monitors. Usage: !start_round""" await refresh_enemy_list() await assign_all_enemies(ctx.channel) await ctx.send("Round started — assignments made and monitors running.") @bot.command(name="stop_round") async def stop_round(ctx): """Stop all monitors and clear assignments. Usage: !stop_round""" # Cancel monitor tasks for t in list(monitor_tasks.values()): t.cancel() monitor_tasks.clear() async with state_lock: state.assignments.clear() save_state() await ctx.send("Stopped round: monitors cancelled and assignments cleared.") @bot.command(name="status") async def status(ctx): """Show current assignments and queue status.""" async with state_lock: parts = state.participants enemies = state.enemy_list assigns = state.assignments.copy() msg = f"Participants: {len(parts)}\nEnemies queued: {len(enemies)}\nAssignments: {len(assigns)}\n" if assigns: for eid, rec in assigns.items(): enemy_name = rec["enemy"].get("name", "?") assignee = rec["assigned_to"].get("display_name", "?") attempts = rec.get("attempts", 0) msg += f"- {enemy_name} (ID {eid}) -> {assignee} (attempts {attempts})\n" await ctx.send(msg) @bot.command(name="manual_assign") @commands.has_permissions(administrator=True) async def manual_assign(ctx, enemy_id: int, member: discord.Member): """Manually assign a specific enemy to a participant. Usage: !manual_assign @member""" enemy = None async with state_lock: for e in state.enemy_list: if int(e["player_id"]) == int(enemy_id): enemy = e break if enemy is None: await ctx.send("Enemy not found in current enemy list — try refreshing with !refresh_enemies") return # make assignment record rec = { "enemy": enemy, "assigned_to": Participant(member.id, member.display_name).to_dict(), "assigned_at": asyncio.get_event_loop().time(), "attempts": 0, "last_pinged": None, "reason": "manual", } state.assignments[int(enemy_id)] = rec save_state() # start monitor task = asyncio.create_task(monitor_assignment(ctx.guild, ctx.channel, int(enemy_id))) monitor_tasks[int(enemy_id)] = task await ctx.send(f"Manually assigned enemy {enemy.get('name','?')} to {member.display_name}.") # --------------------------- # Clean shutdown # --------------------------- async def shutdown(): await torn.close() await bot.close() # --------------------------- # Run the bot # --------------------------- if __name__ == "__main__": try: bot.run(DISCORD_TOKEN) except KeyboardInterrupt: log.info("Interrupted — shutting down") asyncio.run(shutdown())