diff --git a/__pycache__/config.cpython-311.pyc b/__pycache__/config.cpython-311.pyc index 8c658f4..6ecb7f4 100644 Binary files a/__pycache__/config.cpython-311.pyc and b/__pycache__/config.cpython-311.pyc differ diff --git a/config.py b/config.py index 2bb585a..fd1cfae 100644 --- a/config.py +++ b/config.py @@ -37,3 +37,6 @@ REASSIGN_DELAY = _config.get("REASSIGN_DELAY", 120) # Bot Assignment Settings ASSIGNMENT_TIMEOUT = _config.get("ASSIGNMENT_TIMEOUT", 60) # Seconds before reassigning a target ASSIGNMENT_REMINDER = _config.get("ASSIGNMENT_REMINDER", 45) # Seconds before sending reminder message + +# Chain Timer Settings +CHAIN_TIMER_THRESHOLD = _config.get("CHAIN_TIMER_THRESHOLD", 5) # Minutes - start assigning hits when chain timer is at or below this diff --git a/main.py b/main.py index 659cad6..9c88d7a 100644 --- a/main.py +++ b/main.py @@ -17,9 +17,8 @@ from services.bot_assignment import BotAssignmentManager from routers import pages, factions, assignments, discord_mappings, config from routers import bot as bot_router -# ============================================================ + # FastAPI Setup -# ============================================================ app = FastAPI() app.mount("/static", StaticFiles(directory="static"), name="static") @@ -32,9 +31,8 @@ app.include_router(bot_router.router) app.include_router(discord_mappings.router) app.include_router(config.router) -# ============================================================ + # Discord Bot Setup -# ============================================================ intents = discord.Intents.default() intents.message_content = True @@ -96,9 +94,7 @@ async def start_bot(): except Exception as e: print(f"ERROR starting Discord bot: {e}") -# ============================================================ # Main Entry Point -# ============================================================ async def main(): # Start Discord bot in background bot_task = asyncio.create_task(start_bot()) diff --git a/routers/__pycache__/config.cpython-311.pyc b/routers/__pycache__/config.cpython-311.pyc index caf0e02..6045274 100644 Binary files a/routers/__pycache__/config.cpython-311.pyc and b/routers/__pycache__/config.cpython-311.pyc differ diff --git a/routers/config.py b/routers/config.py index a56382e..4172076 100644 --- a/routers/config.py +++ b/routers/config.py @@ -42,7 +42,8 @@ async def get_config(): "HIT_CHECK_INTERVAL": config_module.HIT_CHECK_INTERVAL, "REASSIGN_DELAY": config_module.REASSIGN_DELAY, "ASSIGNMENT_TIMEOUT": config_module.ASSIGNMENT_TIMEOUT, - "ASSIGNMENT_REMINDER": config_module.ASSIGNMENT_REMINDER + "ASSIGNMENT_REMINDER": config_module.ASSIGNMENT_REMINDER, + "CHAIN_TIMER_THRESHOLD": config_module.CHAIN_TIMER_THRESHOLD } else: with open(path, "r", encoding="utf-8") as f: @@ -81,7 +82,8 @@ async def update_config(req: ConfigUpdateRequest): "HIT_CHECK_INTERVAL": config_module.HIT_CHECK_INTERVAL, "REASSIGN_DELAY": config_module.REASSIGN_DELAY, "ASSIGNMENT_TIMEOUT": config_module.ASSIGNMENT_TIMEOUT, - "ASSIGNMENT_REMINDER": config_module.ASSIGNMENT_REMINDER + "ASSIGNMENT_REMINDER": config_module.ASSIGNMENT_REMINDER, + "CHAIN_TIMER_THRESHOLD": config_module.CHAIN_TIMER_THRESHOLD } } diff --git a/services/__pycache__/bot_assignment.cpython-311.pyc b/services/__pycache__/bot_assignment.cpython-311.pyc index c1dcd73..61e484f 100644 Binary files a/services/__pycache__/bot_assignment.cpython-311.pyc and b/services/__pycache__/bot_assignment.cpython-311.pyc differ diff --git a/services/__pycache__/server_state.cpython-311.pyc b/services/__pycache__/server_state.cpython-311.pyc index cd6fb34..c19bd1c 100644 Binary files a/services/__pycache__/server_state.cpython-311.pyc and b/services/__pycache__/server_state.cpython-311.pyc differ diff --git a/services/__pycache__/torn_api.cpython-311.pyc b/services/__pycache__/torn_api.cpython-311.pyc index c3dafe7..0497d55 100644 Binary files a/services/__pycache__/torn_api.cpython-311.pyc and b/services/__pycache__/torn_api.cpython-311.pyc differ diff --git a/services/bot_assignment.py b/services/bot_assignment.py index bd12cdc..2757355 100644 --- a/services/bot_assignment.py +++ b/services/bot_assignment.py @@ -1,11 +1,12 @@ # services/bot_assignment.py import asyncio import json +import aiohttp from pathlib import Path from typing import Dict, Optional from datetime import datetime from services.server_state import STATE -from config import ASSIGNMENT_TIMEOUT, ASSIGNMENT_REMINDER, ALLOWED_CHANNEL_ID +from config import ASSIGNMENT_TIMEOUT, ASSIGNMENT_REMINDER, ALLOWED_CHANNEL_ID, CHAIN_TIMER_THRESHOLD, TORN_API_KEY class BotAssignmentManager: def __init__(self, bot): @@ -15,6 +16,14 @@ class BotAssignmentManager: self.running = False self.task = None + # Chain monitoring state + self.chain_timeout = 0 # Seconds remaining on chain + self.chain_active = False # Whether we're in assignment mode + self.assigned_friendlies = set() # Track who has been assigned in this chain session + self.current_group_index = 0 # For round-robin group assignment + self.chain_warning_sent = False # Track if 30-second warning was sent + self.last_chain_check = datetime.now() + # Load Discord mapping self.load_discord_mapping() @@ -38,6 +47,27 @@ class BotAssignmentManager: """Get Discord user ID for a Torn player ID""" return self.discord_mapping.get(torn_id) + async def fetch_chain_timer(self) -> Optional[int]: + """Fetch chain timeout from Torn API. Returns seconds remaining, or None if no chain or error.""" + if not STATE.friendly_faction_id: + return None + + try: + url = f"https://api.torn.com/v2/faction/{STATE.friendly_faction_id}/chain?key={TORN_API_KEY}" + async with aiohttp.ClientSession() as session: + async with session.get(url) as resp: + if resp.status != 200: + return None + data = await resp.json() + + # Get timeout value in seconds + chain_data = data.get("chain", {}) + timeout = chain_data.get("timeout", 0) + return timeout + except Exception as e: + print(f"Error fetching chain timer: {e}") + return None + async def start(self): """Start the bot assignment loop""" if self.running: @@ -119,10 +149,125 @@ class BotAssignmentManager: return eid return None + async def check_chain_timer(self): + """Check chain timer and update chain state""" + timeout = await self.fetch_chain_timer() + + if timeout is None: + # No chain data or error + if self.chain_active: + print("Chain ended or API error - resetting chain state") + self.chain_active = False + self.assigned_friendlies.clear() + self.current_group_index = 0 + self.chain_warning_sent = False + return + + self.chain_timeout = timeout + threshold_seconds = CHAIN_TIMER_THRESHOLD * 60 + + # Check if we should enter chain mode + if timeout > 0 and timeout <= threshold_seconds and not self.chain_active: + print(f"Chain timer at {timeout}s (threshold: {threshold_seconds}s) - entering chain mode") + self.chain_active = True + self.assigned_friendlies.clear() + self.current_group_index = 0 + self.chain_warning_sent = False + + # Check if chain expired + elif timeout > threshold_seconds and self.chain_active: + print(f"Chain timer above threshold ({timeout}s > {threshold_seconds}s) - exiting chain mode") + self.chain_active = False + self.assigned_friendlies.clear() + self.current_group_index = 0 + self.chain_warning_sent = False + + # Check if chain expired (timeout = 0) + elif timeout == 0 and self.chain_active: + print("Chain expired - resetting chain state") + self.chain_active = False + self.assigned_friendlies.clear() + self.current_group_index = 0 + self.chain_warning_sent = False + + # Send 30-second warning + if self.chain_active and timeout <= 30 and not self.chain_warning_sent: + await self.send_chain_expiration_warning() + self.chain_warning_sent = True + + async def send_chain_expiration_warning(self): + """Send @here alert that chain is about to expire""" + try: + channel = self.bot.get_channel(ALLOWED_CHANNEL_ID) + if channel and STATE.friendly_faction_id: + faction_link = f"https://www.torn.com/factions.php?step=your#/tab=chain" + message = f"@here **CHAIN EXPIRING IN 30 SECONDS!** Attack a faction member to keep it alive!\n{faction_link}" + await channel.send(message) + print("Sent chain expiration warning") + except Exception as e: + print(f"Error sending chain warning: {e}") + + async def assign_next_chain_hit(self): + """Assign next hit in round-robin fashion through groups""" + # Only assign if there's no active assignment waiting + if self.active_targets: + return # Wait for current assignment to complete + + # Get list of group IDs (sorted for consistency) + group_ids = sorted(STATE.groups.keys()) + if not group_ids: + return + + # Try to find a group with available friendly and enemy + attempts = 0 + while attempts < len(group_ids): + # Get current group + group_id = group_ids[self.current_group_index % len(group_ids)] + + async with STATE.lock: + friendly_ids = STATE.groups[group_id].get("friendly", []) + enemy_ids = STATE.groups[group_id].get("enemy", []) + + # Find a friendly who hasn't been assigned yet + available_friendly = None + for fid in friendly_ids: + if fid not in self.assigned_friendlies: + available_friendly = fid + break + + # Find an attackable enemy + enemy_id = self.get_next_enemy_in_group(group_id, enemy_ids) + + if available_friendly and enemy_id: + # Make assignment + self.assigned_friendlies.add(available_friendly) + await self.assign_target(group_id, available_friendly, enemy_id) + print(f"Chain hit assigned: Group {group_id}, Friendly {available_friendly} -> Enemy {enemy_id}") + + # Move to next group for next assignment + self.current_group_index += 1 + return + + # Move to next group and try again + self.current_group_index += 1 + attempts += 1 + + # If we've tried all groups and no assignments possible + # Check if we need to reset assigned_friendlies (everyone has been assigned) + async with STATE.lock: + all_friendlies = set() + for assignments in STATE.groups.values(): + all_friendlies.update(assignments.get("friendly", [])) + + if self.assigned_friendlies and self.assigned_friendlies >= all_friendlies: + print("All friendlies have been assigned - resetting for round-robin") + self.assigned_friendlies.clear() + self.current_group_index = 0 + async def assignment_loop(self): - """Main loop that assigns targets and monitors status""" + """Main loop that monitors chain timer and assigns targets""" await self.bot.wait_until_ready() - print("Bot is ready, assignment loop running") + print("Bot is ready, assignment loop running with chain timer monitoring") first_run = True while self.running: @@ -136,31 +281,18 @@ class BotAssignmentManager: continue if first_run: - print("Bot activated - processing assignments") + print("Bot activated - chain timer monitoring enabled") 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", []) + # Check chain timer every 30 seconds + now = datetime.now() + if (now - self.last_chain_check).total_seconds() >= 30: + await self.check_chain_timer() + self.last_chain_check = now - 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!") + # If chain is active, try to assign next hit + if self.chain_active: + await self.assign_next_chain_hit() # Monitor active targets for status changes or timeouts await self.monitor_active_targets() diff --git a/services/server_state.py b/services/server_state.py index 5ae1597..e9075f2 100644 --- a/services/server_state.py +++ b/services/server_state.py @@ -28,6 +28,9 @@ class ServerState: # bot running flag self.bot_running: bool = False + # faction IDs for API monitoring + self.friendly_faction_id: Optional[int] = None + # concurrency lock for async safety self.lock = asyncio.Lock() diff --git a/services/torn_api.py b/services/torn_api.py index 83d9358..31e8606 100644 --- a/services/torn_api.py +++ b/services/torn_api.py @@ -5,9 +5,8 @@ from config import TORN_API_KEY from .ffscouter import fetch_batch_stats from .server_state import STATE -# ----------------------------- + # Tasks -# ----------------------------- friendly_status_task = None enemy_status_task = None @@ -15,9 +14,8 @@ enemy_status_task = None friendly_lock = asyncio.Lock() enemy_lock = asyncio.Lock() -# ----------------------------- + # Populate faction (memory only) -# ----------------------------- async def populate_faction(faction_id: int, kind: str): """ Fetch members + FFScouter estimates once and store in STATE. @@ -64,9 +62,8 @@ async def populate_faction(faction_id: int, kind: str): return True -# ----------------------------- + # Status refresh loop -# ----------------------------- async def refresh_status_loop(faction_id: int, kind: str, lock: asyncio.Lock, interval: int): """ Periodically refresh member statuses in STATE. @@ -95,10 +92,11 @@ async def refresh_status_loop(faction_id: int, kind: str, lock: asyncio.Lock, in await asyncio.sleep(interval) -# ----------------------------- + # Public API helpers -# ----------------------------- async def populate_friendly(faction_id: int): + # Store friendly faction ID for chain monitoring + STATE.friendly_faction_id = faction_id return await populate_faction(faction_id, "friendly") async def populate_enemy(faction_id: int): diff --git a/static/config.js b/static/config.js index 89180e8..d0301f9 100644 --- a/static/config.js +++ b/static/config.js @@ -8,7 +8,8 @@ const CONFIG_FIELDS = { "HIT_CHECK_INTERVAL": "hit-check-interval", "REASSIGN_DELAY": "reassign-delay", "ASSIGNMENT_TIMEOUT": "assignment-timeout", - "ASSIGNMENT_REMINDER": "assignment-reminder" + "ASSIGNMENT_REMINDER": "assignment-reminder", + "CHAIN_TIMER_THRESHOLD": "chain-timer-threshold" }; let sensitiveFields = []; diff --git a/templates/config.html b/templates/config.html index bfb0434..a98a30c 100644 --- a/templates/config.html +++ b/templates/config.html @@ -91,6 +91,17 @@ + + +
Start assigning hits when chain timer is at or below this many minutes
+ + +