Compare commits

...

2 Commits

Author SHA1 Message Date
7cf882959b Stylized member cards with static fields 2025-11-27 02:44:52 -05:00
317442b8ea Formatted webpage with correct faction member calls 2025-11-27 00:58:39 -05:00
9 changed files with 483 additions and 163 deletions

Binary file not shown.

View File

@@ -1,5 +1,5 @@
from discord.ext import commands 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): class HitCommands(commands.Cog):

117
main.py
View File

@@ -6,6 +6,8 @@ from cogs.commands import HitCommands
import asyncio import asyncio
import uvicorn import uvicorn
import json
from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -13,47 +15,97 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel 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() app = FastAPI()
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
# -----------------------------
# Dashboard page # ============================================================
# ----------------------------- # Dashboard Webpage
# ============================================================
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request): async def dashboard(request: Request):
print(">>> DASHBOARD ROUTE LOADED") print(">>> DASHBOARD ROUTE LOADED")
return templates.TemplateResponse("dashboard.html", {"request": request}) return templates.TemplateResponse("dashboard.html", {"request": request})
# -----------------------------
# Pydantic model for JSON payloads # ============================================================
# ----------------------------- # Pydantic model for JSON POST input
# ============================================================
class FactionRequest(BaseModel): class FactionRequest(BaseModel):
faction_id: int faction_id: int
interval: 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 = discord.Intents.default()
intents.message_content = True intents.message_content = True
@@ -62,9 +114,9 @@ enemy_queue = []
active_assignments = {} active_assignments = {}
round_robin_index = 0 round_robin_index = 0
class HitDispatchBot(commands.Bot): class HitDispatchBot(commands.Bot):
async def setup_hook(self): async def setup_hook(self):
# Load cogs with injected state
await self.add_cog( await self.add_cog(
Assignments( Assignments(
self, self,
@@ -72,9 +124,10 @@ class HitDispatchBot(commands.Bot):
active_assignments=active_assignments, active_assignments=active_assignments,
enrolled_attackers=enrolled_attackers, enrolled_attackers=enrolled_attackers,
hit_check=HIT_CHECK_INTERVAL, hit_check=HIT_CHECK_INTERVAL,
reassign_delay=REASSIGN_DELAY reassign_delay=REASSIGN_DELAY,
) )
) )
await self.add_cog( await self.add_cog(
HitCommands( HitCommands(
self, self,
@@ -86,25 +139,31 @@ class HitDispatchBot(commands.Bot):
async def cog_check(self, ctx): async def cog_check(self, ctx):
return ctx.channel.id == ALLOWED_CHANNEL_ID return ctx.channel.id == ALLOWED_CHANNEL_ID
bot = HitDispatchBot(command_prefix="!", intents=intents) bot = HitDispatchBot(command_prefix="!", intents=intents)
@bot.event @bot.event
async def on_ready(): async def on_ready():
print(f"Logged in as {bot.user.name}") print(f"Logged in as {bot.user.name}")
TOKEN = "YOUR_DISCORD_TOKEN" TOKEN = "YOUR_DISCORD_TOKEN"
async def start_bot(): async def start_bot():
await bot.start(TOKEN) await bot.start(TOKEN)
# -----------------------------
# Main entry # ============================================================
# ----------------------------- # Main Entry Point
# ============================================================
if __name__ == "__main__": if __name__ == "__main__":
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
# Start Discord bot in background # Start Discord bot in background
loop.create_task(start_bot()) 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) uvicorn.run(app, host="127.0.0.1", port=8000)

View File

@@ -3,31 +3,34 @@ import aiohttp
import json import json
import asyncio import asyncio
from pathlib import Path 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 from .ffscouter import fetch_batch_stats
ENEMY_FILE = Path("data/enemy_faction.json") FRIENDLY_MEMBERS_FILE = Path("data/friendly_members.json")
FRIENDLY_FILE = Path("data/friendly_faction.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 # Tasks
enemy_task = None friendly_status_task = None
friendly_task = None enemy_status_task = None
current_enemy_id = None # Locks
current_friendly_id = None friendly_lock = asyncio.Lock()
enemy_lock = asyncio.Lock()
async def fetch_and_save_faction(faction_id: int, file_path: Path) -> bool: # -----------------------------
""" # Static population (once)
Fetches faction members from Torn, fetches their estimated BS from FFScouter, # -----------------------------
and saves everything to a JSON file. 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}" url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}"
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url) as resp: async with session.get(url) as resp:
if resp.status != 200: if resp.status != 200:
print(f"Torn faction fetch error: {resp.status}") print(f"Error fetching faction {faction_id}: {resp.status}")
return False return False
data = await resp.json() data = await resp.json()
@@ -35,91 +38,95 @@ async def fetch_and_save_faction(faction_id: int, file_path: Path) -> bool:
if not members_list: if not members_list:
return False return False
# Build list of IDs (Torn uses 'id', not 'player_id') member_ids = [m.get("id") for m in members_list if "id" in m]
member_ids = [info.get("id") for info in members_list if "id" in info]
if not member_ids: if not member_ids:
return False return False
# Fetch batch FFScouter stats # Fetch FFScouter data once
ff_data = await fetch_batch_stats(member_ids) # returns dict keyed by player_id ff_data = await fetch_batch_stats(member_ids)
# Build final faction data
faction_data = []
for info in members_list:
pid = info.get("id")
if pid is None:
continue
# 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", "?") est = ff_data.get(str(pid), {}).get("bs_estimate_human", "?")
member = { member = {
"id": pid, "id": pid,
"name": info.get("name", "Unknown"), "name": m.get("name", "Unknown"),
"level": info.get("level", 0), "level": m.get("level", 0),
"estimate": est "estimate": est,
} }
faction_data.append(member) members.append(member)
# initial status
status_data[pid] = {"status": m.get("status", {}).get("state", "Unknown")}
# Save to file # Save members
file_path.parent.mkdir(exist_ok=True, parents=True) # ensure folder exists members_file.parent.mkdir(exist_ok=True, parents=True)
with open(file_path, "w", encoding="utf-8") as f: with open(members_file, "w", encoding="utf-8") as f:
json.dump(faction_data, f, indent=2) 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 return True
# -----------------------------
# Status refresh loop
#Loop for the constant update of members and the stop function # -----------------------------
async def refresh_status_loop(faction_id: int, status_file: Path, lock: asyncio.Lock, interval: int):
async def stop_task_if_running(task: asyncio.Task | None): """Refresh only status from Torn API periodically."""
"""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.
"""
while True: while True:
try: 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: 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) 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 async def populate_enemy(faction_id: int):
if new_faction_id != current_enemy_id: return await populate_faction(faction_id, ENEMY_MEMBERS_FILE, ENEMY_STATUS_FILE)
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
# Start new loop
enemy_task = asyncio.create_task( async def start_friendly_status_loop(faction_id: int, interval: int):
faction_loop(new_faction_id, ENEMY_FILE, interval) 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): async def start_enemy_status_loop(faction_id: int, interval: int):
global friendly_task, current_friendly_id global enemy_status_task
if enemy_status_task and not enemy_status_task.done():
if new_faction_id != current_friendly_id: enemy_status_task.cancel()
print(f"[FRIENDLY] Changing faction from {current_friendly_id}{new_faction_id}") enemy_status_task = asyncio.create_task(
await stop_task_if_running(friendly_task) refresh_status_loop(faction_id, ENEMY_STATUS_FILE, enemy_lock, interval)
current_friendly_id = new_faction_id
friendly_task = asyncio.create_task(
faction_loop(new_faction_id, FRIENDLY_FILE, interval)
) )

View File

@@ -1,36 +1,158 @@
async function updateEnemy() { // dashboard.js
const factionId = parseInt(document.getElementById("enemyId").value);
const interval = parseInt(document.getElementById("refreshInterval").value);
if (!factionId || !interval) { const friendlyMembers = new Map();
alert("Please enter Enemy Faction ID and Refresh Interval!"); const enemyMembers = new Map();
return;
}
await fetch(`/api/update_enemy_faction`, { const friendlyContainer = document.getElementById("friendly-container");
method: "POST", const enemyContainer = document.getElementById("enemy-container");
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ faction_id: factionId, interval: interval }) 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() { function updateStatusColor(member) {
const factionId = parseInt(document.getElementById("friendlyId").value); const statusSpan = member.domElement.querySelector(".status-text");
statusSpan.classList.remove("status-ok", "status-traveling", "status-hospitalized");
if (!factionId) { if (!member.status) return;
alert("Please enter Friendly Faction ID!");
return;
}
const interval = parseInt(document.getElementById("refreshInterval").value); const s = member.status.toLowerCase();
if (!interval) { if (s === "okay") statusSpan.classList.add("status-ok");
alert("Please enter Refresh Interval!"); else if (s === "traveling" || s === "abroad") statusSpan.classList.add("status-traveling");
return; else if (s === "hospitalized") statusSpan.classList.add("status-hospitalized");
}
await fetch(`/api/update_friendly_faction`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ faction_id: factionId, interval: interval })
});
} }
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

@@ -4,43 +4,74 @@ body {
color: #f0f0f0; color: #f0f0f0;
margin: 0; margin: 0;
padding: 0; padding: 0;
min-height: 100vh;
} }
.container { .container {
max-width: 800px; width: 95%;
max-width: 1300px;
margin: 2rem auto; margin: 2rem auto;
padding: 2rem; }
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.interval-box {
background-color: #3a3a4d;
padding: 0.75rem 1rem;
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.interval-box label {
font-size: 0.9rem;
color: #ffcc66;
}
.interval-box input {
padding: 0.5rem;
border-radius: 6px;
border: none;
}
.faction-row {
display: flex;
flex-direction: row !important;
justify-content: space-between;
gap: 2rem;
}
.faction-card {
flex: 1;
background-color: #2c2c3e; background-color: #2c2c3e;
padding: 1.5rem;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 0 20px rgba(0,0,0,0.5); box-shadow: 0 0 20px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 600px;
} }
h1 { .faction-card h2 {
text-align: center;
color: #ffcc00;
}
.faction-section {
background-color: #3a3a4d;
margin: 1.5rem 0;
padding: 1rem 1.5rem;
border-radius: 8px;
}
.faction-section h2 {
color: #66ccff; color: #66ccff;
} }
input[type="number"] { input[type="number"] {
padding: 0.5rem; padding: 0.7rem;
margin-right: 0.5rem;
border-radius: 6px; border-radius: 6px;
border: none; border: none;
width: 150px;
} }
button { button {
padding: 0.5rem 1rem; padding: 0.7rem 1rem;
border-radius: 6px; border-radius: 6px;
border: none; border: none;
background-color: #66ccff; background-color: #66ccff;
@@ -53,3 +84,89 @@ button {
button:hover { button:hover {
background-color: #3399ff; 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

@@ -7,19 +7,34 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<!-- Top bar: Title + Refresh Interval -->
<div class="top-bar">
<h1>War Dashboard</h1> <h1>War Dashboard</h1>
<div class="interval-box">
<div class="faction-section"> <label for="refresh-interval">Refresh Interval (seconds)</label>
<h2>Enemy Faction</h2> <input type="number" id="refresh-interval" value="10" min="1">
<input type="number" id="enemyId" placeholder="Enemy Faction ID"> </div>
<input type="number" id="refreshInterval" placeholder="Refresh Interval (sec)">
<button onclick="updateEnemy()">Refresh Enemy</button>
</div> </div>
<div class="faction-section"> <!-- Faction row -->
<div class="faction-row">
<!-- Friendly Faction -->
<div class="faction-card">
<h2>Friendly Faction</h2> <h2>Friendly Faction</h2>
<input type="number" id="friendlyId" placeholder="Friendly Faction ID"> <input type="number" id="friendly-id" placeholder="Faction ID">
<button onclick="updateFriendly()">Refresh Friendly</button> <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>
<!-- 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> </div>