First code implementation

This commit is contained in:
2025-11-23 03:12:03 -05:00
parent 7fe303c6a0
commit ec44cd645b
2 changed files with 488 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.venv

487
main.py Normal file
View File

@@ -0,0 +1,487 @@
#!/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 <enemy_id> @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())