diff --git a/__pycache__/main.cpython-311.pyc b/__pycache__/main.cpython-311.pyc index 21493d8..93572d5 100644 Binary files a/__pycache__/main.cpython-311.pyc and b/__pycache__/main.cpython-311.pyc differ diff --git a/cogs/__pycache__/commands.cpython-311.pyc b/cogs/__pycache__/commands.cpython-311.pyc index 256618d..f09a539 100644 Binary files a/cogs/__pycache__/commands.cpython-311.pyc and b/cogs/__pycache__/commands.cpython-311.pyc differ diff --git a/cogs/commands.py b/cogs/commands.py index 7946083..1430427 100644 --- a/cogs/commands.py +++ b/cogs/commands.py @@ -1,5 +1,5 @@ from discord.ext import commands -from services.torn_api import update_enemy_faction, update_friendly_faction +#from services.torn_api import update_enemy_faction, update_friendly_faction class HitCommands(commands.Cog): diff --git a/main.py b/main.py index 32e34f9..4de8cdf 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,8 @@ from cogs.commands import HitCommands import asyncio import uvicorn +import json +from pathlib import Path from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse @@ -13,47 +15,97 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from pydantic import BaseModel -from services.torn_api import update_enemy_faction, update_friendly_faction +from services.torn_api import populate_friendly, populate_enemy, start_friendly_status_loop, start_enemy_status_loop + + + +# ============================================================ +# FastAPI Setup +# ============================================================ -# ----------------------------- -# FastAPI setup -# ----------------------------- app = FastAPI() templates = Jinja2Templates(directory="templates") app.mount("/static", StaticFiles(directory="static"), name="static") -# ----------------------------- -# Dashboard page -# ----------------------------- + +# ============================================================ +# Dashboard Webpage +# ============================================================ + @app.get("/", response_class=HTMLResponse) async def dashboard(request: Request): print(">>> DASHBOARD ROUTE LOADED") return templates.TemplateResponse("dashboard.html", {"request": request}) -# ----------------------------- -# Pydantic model for JSON payloads -# ----------------------------- + +# ============================================================ +# Pydantic model for JSON POST input +# ============================================================ + class FactionRequest(BaseModel): faction_id: int interval: int -# ----------------------------- -# API Endpoints -# ----------------------------- -@app.post("/api/update_enemy_faction") -async def api_enemy(data: FactionRequest): - await update_enemy_faction(data.faction_id, data.interval) - return {"status": "enemy loop running", "id": data.faction_id, "interval": data.interval} - -@app.post("/api/update_friendly_faction") -async def api_friendly(data: FactionRequest): - await update_friendly_faction(data.faction_id, data.interval) - return {"status": "friendly loop running", "id": data.faction_id, "interval": data.interval} # ----------------------------- -# Discord bot setup +# Populate endpoints (populate JSON once) # ----------------------------- +@app.post("/api/populate_friendly") +async def api_populate_friendly(data: FactionRequest): + from services.torn_api import populate_friendly + await populate_friendly(data.faction_id) + return {"status": "friendly populated", "id": data.faction_id} + +@app.post("/api/populate_enemy") +async def api_populate_enemy(data: FactionRequest): + from services.torn_api import populate_enemy + await populate_enemy(data.faction_id) + return {"status": "enemy populated", "id": data.faction_id} + + +# ----------------------------- +# Start status refresh loops +# ----------------------------- +@app.post("/api/start_friendly_status") +async def api_start_friendly_status(data: FactionRequest): + from services.torn_api import start_friendly_status_loop + await start_friendly_status_loop(data.faction_id, data.interval) + return {"status": "friendly status loop started", "id": data.faction_id, "interval": data.interval} + +@app.post("/api/start_enemy_status") +async def api_start_enemy_status(data: FactionRequest): + from services.torn_api import start_enemy_status_loop + await start_enemy_status_loop(data.faction_id, data.interval) + return {"status": "enemy status loop started", "id": data.faction_id, "interval": data.interval} + + +# ============================= +# Member JSON endpoints +# ============================= +@app.get("/api/friendly_members") +async def get_friendly_members(): + path = Path("data/friendly_faction.json") + if not path.exists(): + return [] + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + +@app.get("/api/enemy_members") +async def get_enemy_members(): + path = Path("data/enemy_faction.json") + if not path.exists(): + return [] + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + + + +# ============================================================ +# Discord Bot Setup +# ============================================================ + intents = discord.Intents.default() intents.message_content = True @@ -62,9 +114,9 @@ enemy_queue = [] active_assignments = {} round_robin_index = 0 + class HitDispatchBot(commands.Bot): async def setup_hook(self): - # Load cogs with injected state await self.add_cog( Assignments( self, @@ -72,9 +124,10 @@ class HitDispatchBot(commands.Bot): active_assignments=active_assignments, enrolled_attackers=enrolled_attackers, hit_check=HIT_CHECK_INTERVAL, - reassign_delay=REASSIGN_DELAY + reassign_delay=REASSIGN_DELAY, ) ) + await self.add_cog( HitCommands( self, @@ -86,25 +139,31 @@ class HitDispatchBot(commands.Bot): async def cog_check(self, ctx): return ctx.channel.id == ALLOWED_CHANNEL_ID + bot = HitDispatchBot(command_prefix="!", intents=intents) + @bot.event async def on_ready(): print(f"Logged in as {bot.user.name}") + TOKEN = "YOUR_DISCORD_TOKEN" + async def start_bot(): await bot.start(TOKEN) -# ----------------------------- -# Main entry -# ----------------------------- + +# ============================================================ +# Main Entry Point +# ============================================================ + if __name__ == "__main__": loop = asyncio.get_event_loop() # Start Discord bot in background loop.create_task(start_bot()) - # Run FastAPI (this will keep the loop alive) + # Run FastAPI app — keeps loop alive uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/services/__pycache__/torn_api.cpython-311.pyc b/services/__pycache__/torn_api.cpython-311.pyc index 37a6746..ea1cae7 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/torn_api.py b/services/torn_api.py index 5e85db1..a9989d8 100644 --- a/services/torn_api.py +++ b/services/torn_api.py @@ -3,31 +3,34 @@ import aiohttp import json import asyncio from pathlib import Path -from config import TORN_API_KEY, ENEMY_FACTION_ID, YOUR_FACTION_ID +from config import TORN_API_KEY from .ffscouter import fetch_batch_stats -ENEMY_FILE = Path("data/enemy_faction.json") -FRIENDLY_FILE = Path("data/friendly_faction.json") +FRIENDLY_MEMBERS_FILE = Path("data/friendly_members.json") +FRIENDLY_STATUS_FILE = Path("data/friendly_status.json") +ENEMY_MEMBERS_FILE = Path("data/enemy_members.json") +ENEMY_STATUS_FILE = Path("data/enemy_status.json") -# Track running tasks + current faction IDs -enemy_task = None -friendly_task = None +# Tasks +friendly_status_task = None +enemy_status_task = None -current_enemy_id = None -current_friendly_id = None +# Locks +friendly_lock = asyncio.Lock() +enemy_lock = asyncio.Lock() -async def fetch_and_save_faction(faction_id: int, file_path: Path) -> bool: - """ - Fetches faction members from Torn, fetches their estimated BS from FFScouter, - and saves everything to a JSON file. - """ +# ----------------------------- +# Static population (once) +# ----------------------------- +async def populate_faction(faction_id: int, members_file: Path, status_file: Path): + """Fetch members + FFScouter estimates once and save static info + initial status.""" url = f"https://api.torn.com/v2/faction/{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(f"Torn faction fetch error: {resp.status}") + print(f"Error fetching faction {faction_id}: {resp.status}") return False data = await resp.json() @@ -35,92 +38,95 @@ async def fetch_and_save_faction(faction_id: int, file_path: Path) -> bool: if not members_list: return False - # Build list of IDs (Torn uses 'id', not 'player_id') - member_ids = [info.get("id") for info in members_list if "id" in info] + member_ids = [m.get("id") for m in members_list if "id" in m] if not member_ids: return False - # Fetch batch FFScouter stats - ff_data = await fetch_batch_stats(member_ids) # returns dict keyed by player_id - - # Build final faction data - faction_data = [] - for info in members_list: - pid = info.get("id") - if pid is None: - continue + # Fetch FFScouter data once + ff_data = await fetch_batch_stats(member_ids) + # Build static member list + members = [] + status_data = {} + for m in members_list: + pid = m["id"] est = ff_data.get(str(pid), {}).get("bs_estimate_human", "?") member = { "id": pid, - "name": info.get("name", "Unknown"), - "level": info.get("level", 0), - "status": info.get("status", {}).get("state", "Unknown"), - "estimate": est + "name": m.get("name", "Unknown"), + "level": m.get("level", 0), + "estimate": est, } - faction_data.append(member) + members.append(member) + # initial status + status_data[pid] = {"status": m.get("status", {}).get("state", "Unknown")} - # Save to file - file_path.parent.mkdir(exist_ok=True, parents=True) # ensure folder exists - with open(file_path, "w", encoding="utf-8") as f: - json.dump(faction_data, f, indent=2) + # Save members + members_file.parent.mkdir(exist_ok=True, parents=True) + with open(members_file, "w", encoding="utf-8") as f: + json.dump(members, f, indent=2) + + # Save initial status + with open(status_file, "w", encoding="utf-8") as f: + json.dump(status_data, f, indent=2) return True - - -#Loop for the constant update of members and the stop function - -async def stop_task_if_running(task: asyncio.Task | None): - """Cancel an existing running task safely.""" - if task and not task.done(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - -async def faction_loop(faction_id: int, file_path: Path, interval: int): - """ - Runs fetch_and_save_faction() in a loop forever, waiting `interval` - seconds between iterations. - """ +# ----------------------------- +# Status refresh loop +# ----------------------------- +async def refresh_status_loop(faction_id: int, status_file: Path, lock: asyncio.Lock, interval: int): + """Refresh only status from Torn API periodically.""" while True: try: - await fetch_and_save_faction(faction_id, file_path) + url = f"https://api.torn.com/v2/faction/{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(f"Status fetch error {resp.status}") + await asyncio.sleep(interval) + continue + data = await resp.json() + + members_list = data.get("members", []) + status_data = {m["id"]: {"status": m.get("status", {}).get("state", "Unknown")} for m in members_list} + + # Save status safely + async with lock: + with open(status_file, "w", encoding="utf-8") as f: + json.dump(status_data, f, indent=2) + except Exception as e: - print(f"Error during faction loop for {faction_id}: {e}") + print(f"Error in status loop for {faction_id}: {e}") await asyncio.sleep(interval) +# ----------------------------- +# Helper functions for endpoints +# ----------------------------- +async def populate_friendly(faction_id: int): + return await populate_faction(faction_id, FRIENDLY_MEMBERS_FILE, FRIENDLY_STATUS_FILE) -#Functions to call the loop, maybe add one to just call once? -async def update_enemy_faction(new_faction_id: int, interval: int): - global enemy_task, current_enemy_id - # If faction ID changes → stop old loop - if new_faction_id != current_enemy_id: - print(f"[ENEMY] Changing faction from {current_enemy_id} → {new_faction_id}") - await stop_task_if_running(enemy_task) - current_enemy_id = new_faction_id +async def populate_enemy(faction_id: int): + return await populate_faction(faction_id, ENEMY_MEMBERS_FILE, ENEMY_STATUS_FILE) - # Start new loop - enemy_task = asyncio.create_task( - faction_loop(new_faction_id, ENEMY_FILE, interval) + +async def start_friendly_status_loop(faction_id: int, interval: int): + global friendly_status_task + if friendly_status_task and not friendly_status_task.done(): + friendly_status_task.cancel() + friendly_status_task = asyncio.create_task( + refresh_status_loop(faction_id, FRIENDLY_STATUS_FILE, friendly_lock, interval) ) -async def update_friendly_faction(new_faction_id: int, interval: int): - global friendly_task, current_friendly_id - - if new_faction_id != current_friendly_id: - print(f"[FRIENDLY] Changing faction from {current_friendly_id} → {new_faction_id}") - await stop_task_if_running(friendly_task) - current_friendly_id = new_faction_id - - friendly_task = asyncio.create_task( - faction_loop(new_faction_id, FRIENDLY_FILE, interval) +async def start_enemy_status_loop(faction_id: int, interval: int): + global enemy_status_task + if enemy_status_task and not enemy_status_task.done(): + enemy_status_task.cancel() + enemy_status_task = asyncio.create_task( + refresh_status_loop(faction_id, ENEMY_STATUS_FILE, enemy_lock, interval) ) - diff --git a/static/dashboard.js b/static/dashboard.js index 48fb613..b1e5011 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -1,36 +1,158 @@ -async function updateEnemy() { - const factionId = parseInt(document.getElementById("enemyId").value); - const interval = parseInt(document.getElementById("refreshInterval").value); +// dashboard.js - if (!factionId || !interval) { - alert("Please enter Enemy Faction ID and Refresh Interval!"); - return; - } +const friendlyMembers = new Map(); +const enemyMembers = new Map(); - await fetch(`/api/update_enemy_faction`, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ faction_id: factionId, interval: interval }) +const friendlyContainer = document.getElementById("friendly-container"); +const enemyContainer = document.getElementById("enemy-container"); + +function createMemberCard(member) { + const card = document.createElement("div"); + card.classList.add("member-card"); + card.dataset.id = member.id; + + // Clear innerHTML, create structured divs + const nameDiv = document.createElement("div"); + nameDiv.classList.add("name"); + nameDiv.textContent = member.name; + + const statsDiv = document.createElement("div"); + statsDiv.classList.add("stats"); + statsDiv.innerHTML = ` + Level: ${member.level}
+ Estimate: ${member.estimate}
+ Status: ${member.status || "Unknown"} + `; + + card.appendChild(nameDiv); + card.appendChild(statsDiv); + + // Store reference to DOM element + member.domElement = card; + + // Make card draggable + card.draggable = true; + card.addEventListener("dragstart", e => { + e.dataTransfer.setData("text/plain", member.id); }); + + // Set initial status color + updateStatusColor(member); + + return card; } -async function updateFriendly() { - const factionId = parseInt(document.getElementById("friendlyId").value); +function updateStatusColor(member) { + const statusSpan = member.domElement.querySelector(".status-text"); + statusSpan.classList.remove("status-ok", "status-traveling", "status-hospitalized"); - if (!factionId) { - alert("Please enter Friendly Faction ID!"); - return; - } + if (!member.status) return; - const interval = parseInt(document.getElementById("refreshInterval").value); - if (!interval) { - alert("Please enter Refresh Interval!"); - return; - } - - await fetch(`/api/update_friendly_faction`, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ faction_id: factionId, interval: interval }) - }); + const s = member.status.toLowerCase(); + if (s === "okay") statusSpan.classList.add("status-ok"); + else if (s === "traveling" || s === "abroad") statusSpan.classList.add("status-traveling"); + else if (s === "hospitalized") statusSpan.classList.add("status-hospitalized"); } + +async function loadMembers(faction) { + const url = faction === "friendly" ? "/api/friendly_members" : "/api/enemy_members"; + const response = await fetch(url); + const members = await response.json(); + + const container = faction === "friendly" ? friendlyContainer : enemyContainer; + const map = faction === "friendly" ? friendlyMembers : enemyMembers; + + container.innerHTML = ""; + map.clear(); + + members.forEach(m => { + if (!m.status) m.status = "Unknown"; + const card = createMemberCard(m); + map.set(m.id, m); + container.appendChild(card); + }); + + refreshStatus(faction); +} + +async function refreshStatus(faction) { + const url = faction === "friendly" ? "/api/friendly_status" : "/api/enemy_status"; + const map = faction === "friendly" ? friendlyMembers : enemyMembers; + + try { + const response = await fetch(url); + const statusData = await response.json(); + + Object.keys(statusData).forEach(id => { + const member = map.get(parseInt(id)); + if (!member) return; + member.status = statusData[id].status; + + // Update DOM + const statusSpan = member.domElement.querySelector(".status-text"); + statusSpan.textContent = member.status; + + // Apply correct color class + updateStatusColor(member); + }); + } catch (err) { + console.error("Failed to refresh status:", err); + } +} + +async function populateFriendly() { + const id = parseInt(document.getElementById("friendly-id").value); + if (!id) return alert("Enter a valid faction ID!"); + + await fetch("/api/populate_friendly", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ faction_id: id, interval: 0 }) + }); + + loadMembers("friendly"); +} + +async function populateEnemy() { + const id = parseInt(document.getElementById("enemy-id").value); + if (!id) return alert("Enter a valid faction ID!"); + + await fetch("/api/populate_enemy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ faction_id: id, interval: 0 }) + }); + + loadMembers("enemy"); +} + +async function startFriendlyStatus() { + const id = parseInt(document.getElementById("friendly-id").value); + const interval = parseInt(document.getElementById("refresh-interval").value) || 10; + + await fetch("/api/start_friendly_status", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ faction_id: id, interval }) + }); + + setInterval(() => refreshStatus("friendly"), interval * 1000); +} + +async function startEnemyStatus() { + const id = parseInt(document.getElementById("enemy-id").value); + const interval = parseInt(document.getElementById("refresh-interval").value) || 10; + + await fetch("/api/start_enemy_status", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ faction_id: id, interval }) + }); + + setInterval(() => refreshStatus("enemy"), interval * 1000); +} + +document.getElementById("friendly-populate-btn").addEventListener("click", populateFriendly); +document.getElementById("enemy-populate-btn").addEventListener("click", populateEnemy); +document.getElementById("friendly-status-btn").addEventListener("click", startFriendlyStatus); +document.getElementById("enemy-status-btn").addEventListener("click", startEnemyStatus); diff --git a/static/styles.css b/static/styles.css index abd8908..6c299d7 100644 --- a/static/styles.css +++ b/static/styles.css @@ -13,7 +13,6 @@ body { margin: 2rem auto; } -/* Top Bar with Title + Interval Box */ .top-bar { display: flex; justify-content: space-between; @@ -41,7 +40,6 @@ body { border: none; } -/* Horizontal Faction Row */ .faction-row { display: flex; flex-direction: row !important; @@ -49,7 +47,6 @@ body { gap: 2rem; } -/* Each Faction Card */ .faction-card { flex: 1; background-color: #2c2c3e; @@ -87,3 +84,89 @@ button { button:hover { background-color: #3399ff; } + +.member-list { + margin-top: 1rem; + max-height: 350px; + overflow-y: auto; + background: #1a1a26; + padding: 0.8rem; + border-radius: 10px; +} + +.member-card { + background-color: #3a3a4d; + padding: 1rem; + margin: 0.5rem 0; + border-radius: 10px; + display: flex; + flex-direction: row; /* horizontal layout */ + align-items: center; + justify-content: flex-start; + gap: 2rem; + box-shadow: 0 0 15px rgba(0, 0, 0, 0.5); + cursor: grab; + min-width: 200px; +} + +.member-card:active { + cursor: grabbing; +} + +/* Name section */ +.member-card .name { + font-weight: bold; + color: #66ccff; + min-width: 120px; /* ensures spacing */ +} + +/* Stats section */ +.member-card .stats { + font-size: 0.9rem; + line-height: 1.3; + color: #f0f0f0; +} + +.member-card strong { + font-size: 1rem; + min-width: 100px; +} + +.member-card span { + font-size: 0.9rem; +} + +.member-card:hover { + background-color: #4a4a60; +} + +#friendly-container, +#enemy-container { + max-height: 400px; + overflow-y: auto; + padding: 0.5rem; + border: 1px solid #444; + border-radius: 10px; + background-color: #2c2c3e; +} + +#friendly-container::-webkit-scrollbar, +#enemy-container::-webkit-scrollbar { + width: 8px; +} + +#friendly-container::-webkit-scrollbar-thumb, +#enemy-container::-webkit-scrollbar-thumb { + background-color: #66ccff; + border-radius: 4px; +} + +#friendly-container::-webkit-scrollbar-track, +#enemy-container::-webkit-scrollbar-track { + background-color: #2c2c3e; + border-radius: 4px; +} + +.status-ok { color: #28a745; font-weight: bold; } +.status-traveling { color: #3399ff; font-weight: bold; } +.status-hospitalized { color: #ff4d4d; font-weight: bold; } diff --git a/templates/dashboard.html b/templates/dashboard.html index 78f0f70..b3009b1 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -6,38 +6,38 @@ +
+ +
+

War Dashboard

+
+ + +
+
-
+ +
+ +
+

Friendly Faction

+ + + +
+
-
-

War Dashboard

- -
- - + +
+

Enemy Faction

+ + + +
+
-
- - -
-

Enemy Faction

- - -
- - -
-

Friendly Faction

- - -
- -
- -
- - +