Stylized member cards with static fields

This commit is contained in:
2025-11-27 02:44:52 -05:00
parent 317442b8ea
commit 7cf882959b
9 changed files with 435 additions and 165 deletions

Binary file not shown.

View File

@@ -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):

117
main.py
View File

@@ -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)

View File

@@ -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)
)

View File

@@ -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}<br>
Estimate: ${member.estimate}<br>
Status: <span class="status-text">${member.status || "Unknown"}</span>
`;
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);

View File

@@ -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; }

View File

@@ -6,38 +6,38 @@
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="container">
<!-- Top bar: Title + Refresh Interval -->
<div class="top-bar">
<h1>War Dashboard</h1>
<div class="interval-box">
<label for="refresh-interval">Refresh Interval (seconds)</label>
<input type="number" id="refresh-interval" value="10" min="1">
</div>
</div>
<div class="container">
<!-- Faction row -->
<div class="faction-row">
<!-- Friendly Faction -->
<div class="faction-card">
<h2>Friendly Faction</h2>
<input type="number" id="friendly-id" placeholder="Faction ID">
<button id="friendly-populate-btn">Populate Friendly</button>
<button id="friendly-status-btn">Start Refresh</button>
<div id="friendly-container" class="members-container"></div>
</div>
<div class="top-bar">
<h1>War Dashboard</h1>
<div class="interval-box">
<label for="refreshInterval">Refresh Interval (sec)</label>
<input type="number" id="refreshInterval" placeholder="e.g., 30">
<!-- Enemy Faction -->
<div class="faction-card">
<h2>Enemy Faction</h2>
<input type="number" id="enemy-id" placeholder="Faction ID">
<button id="enemy-populate-btn">Populate Enemy</button>
<button id="enemy-status-btn">Start Refresh</button>
<div id="enemy-container" class="members-container"></div>
</div>
</div>
</div>
<div class="faction-row">
<!-- Enemy -->
<div class="faction-card">
<h2>Enemy Faction</h2>
<input type="number" id="enemyId" placeholder="Enemy Faction ID">
<button onclick="updateEnemy()">Start Enemy Refresh</button>
</div>
<!-- Friendly -->
<div class="faction-card">
<h2>Friendly Faction</h2>
<input type="number" id="friendlyId" placeholder="Friendly Faction ID">
<button onclick="updateFriendly()">Start Friendly Refresh</button>
</div>
</div>
</div>
<script src="/static/dashboard.js"></script>
<script src="/static/dashboard.js"></script>
</body>
</html>