Compare commits
10 Commits
7cf882959b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8779ad3d3b | |||
| c9808759f0 | |||
| 60d209c138 | |||
| 4da1739da4 | |||
| 27d4a87249 | |||
| 82b34f32f7 | |||
| 022cb0eb75 | |||
| 788f2fec2c | |||
| 4b0038a4b7 | |||
| 441ae31eb6 |
18
README.md
18
README.md
@@ -2,16 +2,20 @@ The purpose of this bot will be to assign targets to faction members during war
|
||||
|
||||
Features:
|
||||
- Round Robin assignation of war targets
|
||||
- ability to have members enroll and leave the queue
|
||||
- check if the assigned enemy has been hit after 1 minute, if not reassign
|
||||
- match targets up with members that have the appropriate stats
|
||||
- dashboard to see who is supposed to hit who
|
||||
- maybe also enemy stats
|
||||
|
||||
|
||||
|
||||
ToDo:
|
||||
- move interval button to a neutral spot
|
||||
- sections to list faction memebers
|
||||
- needs to be movable objects
|
||||
- Assignment pools depending on stats
|
||||
- players will be round-robin queued their targets from here
|
||||
- API Key section
|
||||
- Have user enter their own API key, pass along to the functions that call APIs
|
||||
- server side config storage
|
||||
- have multiple managers logged in to make changes
|
||||
- basic auth
|
||||
- since control of the Discord bot would also be through there technically
|
||||
- add status description to member cards
|
||||
|
||||
|
||||
For now let's pivot to the Discord Bot functionality. What the bot is going to do is for each battle group it needs to assign a friendly member to an enemy member. That friendly will then get a ping in Discord by pulling the list of Discord users and matching the player id to the user in the Discord server. It will ping them and say "New target for @user , attack (link to enemy profile) in the next 60 seconds!" If the enemies status does not change to "In Hospital" in the next 60 seconds that enemy will be assigned to the next player in the group that has not received a hit yet. We will also need to keep track of how many hits a friendly has completed. That way if a new friendly enters a pool they will get a chance to attack before the ones that have not had a chance We also need a button on the webpage to start and stop the bot
|
||||
Binary file not shown.
334
assignments.json
Normal file
334
assignments.json
Normal file
@@ -0,0 +1,334 @@
|
||||
{
|
||||
"groups": {
|
||||
"1": {
|
||||
"friendly": [],
|
||||
"enemy": []
|
||||
},
|
||||
"2": {
|
||||
"friendly": [],
|
||||
"enemy": []
|
||||
},
|
||||
"3": {
|
||||
"friendly": [],
|
||||
"enemy": []
|
||||
},
|
||||
"4": {
|
||||
"friendly": [],
|
||||
"enemy": []
|
||||
},
|
||||
"5": {
|
||||
"friendly": [],
|
||||
"enemy": []
|
||||
}
|
||||
},
|
||||
"friendly": {
|
||||
"2323265": {
|
||||
"id": 2323265,
|
||||
"name": "richard2130",
|
||||
"level": 43,
|
||||
"estimate": "117k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"2643888": {
|
||||
"id": 2643888,
|
||||
"name": "CKDGr8",
|
||||
"level": 19,
|
||||
"estimate": "16.6k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"2658249": {
|
||||
"id": 2658249,
|
||||
"name": "Saviour01",
|
||||
"level": 39,
|
||||
"estimate": "151k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"2877889": {
|
||||
"id": 2877889,
|
||||
"name": "Larioon",
|
||||
"level": 48,
|
||||
"estimate": "2.11m",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"2996876": {
|
||||
"id": 2996876,
|
||||
"name": "Dextrooous",
|
||||
"level": 49,
|
||||
"estimate": "2.82m",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3097390": {
|
||||
"id": 3097390,
|
||||
"name": "HendoAL",
|
||||
"level": 12,
|
||||
"estimate": "875",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3119350": {
|
||||
"id": 3119350,
|
||||
"name": "DragonRoy",
|
||||
"level": 41,
|
||||
"estimate": "143k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3246078": {
|
||||
"id": 3246078,
|
||||
"name": "Az4sH",
|
||||
"level": 40,
|
||||
"estimate": "166k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3296065": {
|
||||
"id": 3296065,
|
||||
"name": "TvTBanshee",
|
||||
"level": 43,
|
||||
"estimate": "261k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3571528": {
|
||||
"id": 3571528,
|
||||
"name": "TannedOrange",
|
||||
"level": 35,
|
||||
"estimate": "157k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3572068": {
|
||||
"id": 3572068,
|
||||
"name": "Pasketti",
|
||||
"level": 32,
|
||||
"estimate": "111k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3585878": {
|
||||
"id": 3585878,
|
||||
"name": "Dougo",
|
||||
"level": 41,
|
||||
"estimate": "414k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3612970": {
|
||||
"id": 3612970,
|
||||
"name": "VitaSoyMilk10",
|
||||
"level": 19,
|
||||
"estimate": "14.9k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3615091": {
|
||||
"id": 3615091,
|
||||
"name": "CoinsOperated",
|
||||
"level": 36,
|
||||
"estimate": "1.04m",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3627374": {
|
||||
"id": 3627374,
|
||||
"name": "Brouhaha",
|
||||
"level": 33,
|
||||
"estimate": "87.8k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3639240": {
|
||||
"id": 3639240,
|
||||
"name": "AAAG",
|
||||
"level": 23,
|
||||
"estimate": "127k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3779299": {
|
||||
"id": 3779299,
|
||||
"name": "arcflips",
|
||||
"level": 22,
|
||||
"estimate": "12.2k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3803470": {
|
||||
"id": 3803470,
|
||||
"name": "LioKey",
|
||||
"level": 22,
|
||||
"estimate": "21.6k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3925208": {
|
||||
"id": 3925208,
|
||||
"name": "Snakiter",
|
||||
"level": 17,
|
||||
"estimate": "905",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3925604": {
|
||||
"id": 3925604,
|
||||
"name": "Vyktimizer",
|
||||
"level": 20,
|
||||
"estimate": "17.4k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3929969": {
|
||||
"id": 3929969,
|
||||
"name": "JayzBondz12",
|
||||
"level": 23,
|
||||
"estimate": "54.1k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3930001": {
|
||||
"id": 3930001,
|
||||
"name": "TopBiscuit",
|
||||
"level": 21,
|
||||
"estimate": "16k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3931799": {
|
||||
"id": 3931799,
|
||||
"name": "IVerbYourNoun",
|
||||
"level": 26,
|
||||
"estimate": "78.9k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3956441": {
|
||||
"id": 3956441,
|
||||
"name": "herfieez",
|
||||
"level": 19,
|
||||
"estimate": "12.4k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3999413": {
|
||||
"id": 3999413,
|
||||
"name": "Rarcham",
|
||||
"level": 16,
|
||||
"estimate": "1.71k",
|
||||
"type": "friendly",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
}
|
||||
},
|
||||
"enemy": {
|
||||
"2292261": {
|
||||
"id": 2292261,
|
||||
"name": "Dalil1ne",
|
||||
"level": 31,
|
||||
"estimate": "56.5k",
|
||||
"type": "enemy",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"2323821": {
|
||||
"id": 2323821,
|
||||
"name": "Celwind",
|
||||
"level": 16,
|
||||
"estimate": "2.2k",
|
||||
"type": "enemy",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3876859": {
|
||||
"id": 3876859,
|
||||
"name": "sleezihax",
|
||||
"level": 4,
|
||||
"estimate": "51",
|
||||
"type": "enemy",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3927657": {
|
||||
"id": 3927657,
|
||||
"name": "665monk17",
|
||||
"level": 10,
|
||||
"estimate": "787",
|
||||
"type": "enemy",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3931946": {
|
||||
"id": 3931946,
|
||||
"name": "Jonaketski",
|
||||
"level": 8,
|
||||
"estimate": "550",
|
||||
"type": "enemy",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3937815": {
|
||||
"id": 3937815,
|
||||
"name": "Babetch",
|
||||
"level": 15,
|
||||
"estimate": "1.35k",
|
||||
"type": "enemy",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3972935": {
|
||||
"id": 3972935,
|
||||
"name": "_Fox__",
|
||||
"level": 6,
|
||||
"estimate": "301",
|
||||
"type": "enemy",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"3999582": {
|
||||
"id": 3999582,
|
||||
"name": "Azrien",
|
||||
"level": 6,
|
||||
"estimate": "481",
|
||||
"type": "enemy",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
},
|
||||
"4010926": {
|
||||
"id": 4010926,
|
||||
"name": "_blackFOX_",
|
||||
"level": 3,
|
||||
"estimate": "None",
|
||||
"type": "enemy",
|
||||
"group": null,
|
||||
"hits": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
185
main.py
185
main.py
@@ -1,3 +1,4 @@
|
||||
# main.py (updated)
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from config import ALLOWED_CHANNEL_ID, HIT_CHECK_INTERVAL, REASSIGN_DELAY
|
||||
@@ -8,62 +9,93 @@ import asyncio
|
||||
import uvicorn
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
|
||||
# import server state
|
||||
from services.server_state import STATE, Member
|
||||
|
||||
from services.torn_api import populate_friendly, populate_enemy, start_friendly_status_loop, start_enemy_status_loop
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# FastAPI Setup
|
||||
# ============================================================
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 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 POST input
|
||||
# Pydantic models for JSON POST input
|
||||
# ============================================================
|
||||
|
||||
class FactionRequest(BaseModel):
|
||||
faction_id: int
|
||||
interval: int
|
||||
interval: int = 30
|
||||
|
||||
class AssignMemberRequest(BaseModel):
|
||||
group_id: str # "1", "2", ... "5"
|
||||
kind: str # "friendly" or "enemy"
|
||||
member_id: int
|
||||
|
||||
class RemoveAssignmentRequest(BaseModel):
|
||||
member_id: int
|
||||
|
||||
class BotControl(BaseModel):
|
||||
action: str # "start" or "stop"
|
||||
|
||||
# ================================
|
||||
# Helper: load JSON file into STATE
|
||||
# ================================
|
||||
def _load_json_list(path: Path):
|
||||
if not path.exists():
|
||||
return []
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
async def sync_state_from_file(path: Path, kind: str):
|
||||
"""
|
||||
Read JSON file (list of members dicts) and upsert into STATE.
|
||||
Expected member dict keys: id, name, level, estimate, optionally status/hits.
|
||||
"""
|
||||
arr = _load_json_list(path)
|
||||
received_ids = []
|
||||
for m in arr:
|
||||
try:
|
||||
await STATE.upsert_member(m, kind)
|
||||
received_ids.append(int(m["id"]))
|
||||
except Exception as e:
|
||||
print(f"Skipping bad member entry while syncing: {m} -> {e}")
|
||||
await STATE.remove_missing_members(received_ids, kind)
|
||||
|
||||
# -----------------------------
|
||||
# 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)
|
||||
# sync STATE from file
|
||||
await sync_state_from_file(Path("data/friendly_members.json"), "friendly")
|
||||
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)
|
||||
await sync_state_from_file(Path("data/enemy_memberes.json"), "enemy")
|
||||
return {"status": "enemy populated", "id": data.faction_id}
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Start status refresh loops
|
||||
# -----------------------------
|
||||
@@ -79,33 +111,133 @@ async def api_start_enemy_status(data: FactionRequest):
|
||||
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
|
||||
# Member JSON endpoints (unchanged)
|
||||
# =============================
|
||||
@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)
|
||||
# Return list, but prefer STATE if populated
|
||||
if STATE.friendly:
|
||||
return [m.model_dump() for m in STATE.friendly.values()]
|
||||
# fallback to file
|
||||
path = Path("data/friendly_members.json")
|
||||
return _load_json_list(path)
|
||||
|
||||
@app.get("/api/enemy_members")
|
||||
async def get_enemy_members():
|
||||
path = Path("data/enemy_faction.json")
|
||||
if STATE.enemy:
|
||||
return [m.model_dump() for m in STATE.enemy.values()]
|
||||
path = Path("data/enemy_members.json")
|
||||
return _load_json_list(path)
|
||||
|
||||
# =============================
|
||||
# Status JSON endpoints (unchanged)
|
||||
# =============================
|
||||
@app.get("/api/friendly_status")
|
||||
async def api_friendly_status():
|
||||
path = Path("data/friendly_status.json")
|
||||
if not path.exists():
|
||||
return []
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
@app.get("/api/enemy_status")
|
||||
async def api_enemy_status():
|
||||
path = Path("data/enemy_status.json")
|
||||
if not path.exists():
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
# =============================
|
||||
# Assignment endpoints (server-side)
|
||||
# =============================
|
||||
@app.get("/api/assignments")
|
||||
async def api_get_assignments():
|
||||
"""
|
||||
Return assignments snapshot:
|
||||
{ "1": { "friendly": [...], "enemy": [...] }, "2": {...}, ... }
|
||||
"""
|
||||
snap = await STATE.get_assignments_snapshot()
|
||||
return snap
|
||||
|
||||
@app.post("/api/assign_member")
|
||||
async def api_assign_member(req: AssignMemberRequest):
|
||||
group_id = req.group_id
|
||||
kind = req.kind
|
||||
member_id = req.member_id
|
||||
|
||||
# Validate group
|
||||
if group_id not in STATE.groups:
|
||||
raise HTTPException(status_code=400, detail="invalid group_id")
|
||||
|
||||
# Validate member exists in STATE (if not present, attempt to load from file)
|
||||
if kind not in ("friendly", "enemy"):
|
||||
raise HTTPException(status_code=400, detail="invalid kind")
|
||||
|
||||
coll = STATE.friendly if kind == "friendly" else STATE.enemy
|
||||
if member_id not in coll:
|
||||
# Try to load members from file into STATE and re-check
|
||||
file_path = Path("data/friendly_members.json") if kind == "friendly" else Path("data/enemy_members.json")
|
||||
await sync_state_from_file(file_path, kind)
|
||||
if member_id not in coll:
|
||||
raise HTTPException(status_code=404, detail="member not found")
|
||||
|
||||
await STATE.assign_member(member_id, kind, group_id)
|
||||
return {"status": "ok", "group": group_id, "kind": kind, "member_id": member_id}
|
||||
|
||||
@app.post("/api/remove_member_assignment")
|
||||
async def api_remove_member_assignment(req: RemoveAssignmentRequest):
|
||||
member_id = req.member_id
|
||||
await STATE.remove_member_assignment(member_id)
|
||||
return {"status": "ok", "member_id": member_id}
|
||||
|
||||
@app.post("/api/clear_assignments")
|
||||
async def api_clear_assignments():
|
||||
await STATE.clear_all_assignments()
|
||||
return {"status": "ok"}
|
||||
|
||||
# =============================
|
||||
# Bot control endpoint
|
||||
# =============================
|
||||
@app.post("/api/bot_control")
|
||||
async def api_bot_control(req: BotControl):
|
||||
if req.action not in ("start", "stop"):
|
||||
raise HTTPException(status_code=400, detail="invalid action")
|
||||
STATE.bot_running = (req.action == "start")
|
||||
return {"status": "ok", "bot_running": STATE.bot_running}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Reset Groups Endpoint
|
||||
# ============================================================
|
||||
|
||||
@app.post("/api/reset_groups")
|
||||
async def reset_groups():
|
||||
# Load existing data
|
||||
data = load_json("assigned_groups.json")
|
||||
|
||||
# Clear group assignments
|
||||
for g in data["groups"].values():
|
||||
g.clear()
|
||||
|
||||
# Reset friendly assigned_group
|
||||
for f in data["friendly_members"]:
|
||||
f["assigned_group"] = None
|
||||
|
||||
# Reset enemy assigned_group
|
||||
for e in data["enemy_members"]:
|
||||
e["assigned_group"] = None
|
||||
|
||||
# Save back to file
|
||||
save_json("assigned_groups.json", data)
|
||||
|
||||
return { "success": True }
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Discord Bot Setup
|
||||
# ============================================================
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
|
||||
@@ -114,7 +246,6 @@ enemy_queue = []
|
||||
active_assignments = {}
|
||||
round_robin_index = 0
|
||||
|
||||
|
||||
class HitDispatchBot(commands.Bot):
|
||||
async def setup_hook(self):
|
||||
await self.add_cog(
|
||||
@@ -139,26 +270,20 @@ 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 Point
|
||||
# ============================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
|
||||
BIN
services/__pycache__/server_state.cpython-311.pyc
Normal file
BIN
services/__pycache__/server_state.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
120
services/server_state.py
Normal file
120
services/server_state.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# services/server_state.py
|
||||
from typing import Dict, List, Optional
|
||||
from pydantic import BaseModel
|
||||
import asyncio
|
||||
|
||||
class Member(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
level: int
|
||||
estimate: str
|
||||
type: str # "friendly" or "enemy"
|
||||
group: Optional[str] = None # "1", "2", ...
|
||||
hits: int = 0
|
||||
status: str = "Unknown" # Added status field for Torn API status
|
||||
|
||||
class ServerState:
|
||||
def __init__(self, group_count: int = 5):
|
||||
# member maps
|
||||
self.friendly: Dict[int, Member] = {}
|
||||
self.enemy: Dict[int, Member] = {}
|
||||
# initialize groups 1..group_count as strings "1","2",...
|
||||
self.group_count = group_count
|
||||
self.groups: Dict[str, Dict[str, List[int]]] = {}
|
||||
for i in range(1, group_count + 1):
|
||||
key = str(i)
|
||||
self.groups[key] = {"friendly": [], "enemy": []}
|
||||
|
||||
# bot running flag
|
||||
self.bot_running: bool = False
|
||||
|
||||
# concurrency lock for async safety
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
# Assignment helpers
|
||||
async def assign_member(self, member_id: int, kind: str, group_key: str):
|
||||
async with self.lock:
|
||||
if kind not in ("friendly", "enemy"):
|
||||
raise ValueError("invalid kind")
|
||||
if group_key not in self.groups:
|
||||
raise ValueError("invalid group_key")
|
||||
|
||||
# remove from any existing group
|
||||
for gk, buckets in self.groups.items():
|
||||
if member_id in buckets[kind]:
|
||||
buckets[kind].remove(member_id)
|
||||
|
||||
# add to new group
|
||||
if member_id not in self.groups[group_key][kind]:
|
||||
self.groups[group_key][kind].append(member_id)
|
||||
|
||||
# update member.group
|
||||
coll = self.friendly if kind == "friendly" else self.enemy
|
||||
if member_id in coll:
|
||||
coll[member_id].group = group_key
|
||||
|
||||
async def remove_member_assignment(self, member_id: int):
|
||||
async with self.lock:
|
||||
for gk, buckets in self.groups.items():
|
||||
if member_id in buckets["friendly"]:
|
||||
buckets["friendly"].remove(member_id)
|
||||
if member_id in buckets["enemy"]:
|
||||
buckets["enemy"].remove(member_id)
|
||||
|
||||
# clear group attribute
|
||||
if member_id in self.friendly:
|
||||
self.friendly[member_id].group = None
|
||||
if member_id in self.enemy:
|
||||
self.enemy[member_id].group = None
|
||||
|
||||
async def clear_all_assignments(self):
|
||||
async with self.lock:
|
||||
for gk in self.groups:
|
||||
self.groups[gk]["friendly"].clear()
|
||||
self.groups[gk]["enemy"].clear()
|
||||
for m in self.friendly.values():
|
||||
m.group = None
|
||||
for m in self.enemy.values():
|
||||
m.group = None
|
||||
|
||||
async def get_assignments_snapshot(self) -> Dict[str, Dict[str, List[int]]]:
|
||||
async with self.lock:
|
||||
snap = {}
|
||||
for gk, buckets in self.groups.items():
|
||||
snap[gk] = {
|
||||
"friendly": list(buckets["friendly"]),
|
||||
"enemy": list(buckets["enemy"])
|
||||
}
|
||||
return snap
|
||||
|
||||
# member helpers
|
||||
async def upsert_member(self, member_data: dict, kind: str):
|
||||
async with self.lock:
|
||||
if kind not in ("friendly", "enemy"):
|
||||
raise ValueError("invalid kind")
|
||||
|
||||
coll = self.friendly if kind == "friendly" else self.enemy
|
||||
mid = int(member_data["id"])
|
||||
existing_group = coll.get(mid).group if (mid in coll) else None
|
||||
member = Member(
|
||||
id=mid,
|
||||
name=member_data.get("name", "Unknown"),
|
||||
level=int(member_data.get("level", 0)),
|
||||
estimate=str(member_data.get("estimate", "?")),
|
||||
type=kind,
|
||||
group=existing_group,
|
||||
hits=int(member_data.get("hits", 0)) if "hits" in member_data else 0,
|
||||
status=member_data.get("status", "Unknown") # Initialize status if provided
|
||||
)
|
||||
coll[mid] = member
|
||||
|
||||
async def remove_missing_members(self, received_ids: List[int], kind: str):
|
||||
async with self.lock:
|
||||
coll = self.friendly if kind == "friendly" else self.enemy
|
||||
to_remove = [mid for mid in coll.keys() if mid not in set(received_ids)]
|
||||
for mid in to_remove:
|
||||
await self.remove_member_assignment(mid)
|
||||
del coll[mid]
|
||||
|
||||
# Single global state
|
||||
STATE = ServerState(group_count=5)
|
||||
@@ -1,30 +1,28 @@
|
||||
# services/torn_api.py
|
||||
import aiohttp
|
||||
import json
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from config import TORN_API_KEY
|
||||
from .ffscouter import fetch_batch_stats
|
||||
from .server_state import STATE
|
||||
|
||||
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")
|
||||
|
||||
# -----------------------------
|
||||
# Tasks
|
||||
# -----------------------------
|
||||
friendly_status_task = None
|
||||
enemy_status_task = None
|
||||
|
||||
# Locks
|
||||
# Locks for safe async updates
|
||||
friendly_lock = asyncio.Lock()
|
||||
enemy_lock = asyncio.Lock()
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Static population (once)
|
||||
# Populate faction (memory only)
|
||||
# -----------------------------
|
||||
async def populate_faction(faction_id: int, members_file: Path, status_file: Path):
|
||||
"""Fetch members + FFScouter estimates once and save static info + initial status."""
|
||||
async def populate_faction(faction_id: int, kind: str):
|
||||
"""
|
||||
Fetch members + FFScouter estimates once and store in STATE.
|
||||
kind: "friendly" or "enemy"
|
||||
"""
|
||||
url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@@ -42,42 +40,37 @@ async def populate_faction(faction_id: int, members_file: Path, status_file: Pat
|
||||
if not member_ids:
|
||||
return False
|
||||
|
||||
# Fetch FFScouter data once
|
||||
# Fetch FFScouter estimates
|
||||
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": m.get("name", "Unknown"),
|
||||
"level": m.get("level", 0),
|
||||
"estimate": est,
|
||||
}
|
||||
members.append(member)
|
||||
# initial status
|
||||
status_data[pid] = {"status": m.get("status", {}).get("state", "Unknown")}
|
||||
received_ids = []
|
||||
async with (friendly_lock if kind == "friendly" else enemy_lock):
|
||||
for m in members_list:
|
||||
pid = m["id"]
|
||||
est = ff_data.get(str(pid), {}).get("bs_estimate_human", "?")
|
||||
status = m.get("status", {}).get("state", "Unknown")
|
||||
member_data = {
|
||||
"id": pid,
|
||||
"name": m.get("name", "Unknown"),
|
||||
"level": m.get("level", 0),
|
||||
"estimate": est,
|
||||
"status": status
|
||||
}
|
||||
await STATE.upsert_member(member_data, kind)
|
||||
received_ids.append(pid)
|
||||
|
||||
# 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)
|
||||
# Remove missing members from STATE
|
||||
await STATE.remove_missing_members(received_ids, kind)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# 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."""
|
||||
async def refresh_status_loop(faction_id: int, kind: str, lock: asyncio.Lock, interval: int):
|
||||
"""
|
||||
Periodically refresh member statuses in STATE.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}"
|
||||
@@ -90,43 +83,39 @@ async def refresh_status_loop(faction_id: int, status_file: Path, lock: asyncio.
|
||||
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)
|
||||
coll = STATE.friendly if kind == "friendly" else STATE.enemy
|
||||
for m in members_list:
|
||||
mid = m.get("id")
|
||||
if mid in coll:
|
||||
coll[mid].status = m.get("status", {}).get("state", "Unknown")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in status loop for {faction_id}: {e}")
|
||||
print(f"Error in status loop for {kind} faction {faction_id}: {e}")
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Helper functions for endpoints
|
||||
# Public API helpers
|
||||
# -----------------------------
|
||||
async def populate_friendly(faction_id: int):
|
||||
return await populate_faction(faction_id, FRIENDLY_MEMBERS_FILE, FRIENDLY_STATUS_FILE)
|
||||
|
||||
return await populate_faction(faction_id, "friendly")
|
||||
|
||||
async def populate_enemy(faction_id: int):
|
||||
return await populate_faction(faction_id, ENEMY_MEMBERS_FILE, ENEMY_STATUS_FILE)
|
||||
|
||||
return await populate_faction(faction_id, "enemy")
|
||||
|
||||
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)
|
||||
refresh_status_loop(faction_id, "friendly", friendly_lock, 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)
|
||||
refresh_status_loop(faction_id, "enemy", enemy_lock, interval)
|
||||
)
|
||||
|
||||
@@ -1,134 +1,558 @@
|
||||
// dashboard.js
|
||||
// dashboard.js (Server-side assignments, polling-mode)
|
||||
// - No more localStorage
|
||||
// - Polls /api/assignments every 5s to keep all clients in sync
|
||||
// - POST /api/assign_member on drop, POST /api/remove_member_assignment when returned to list
|
||||
// - Prevents duplicates, preserves card objects and status updates
|
||||
|
||||
const friendlyMembers = new Map();
|
||||
// ---------------------------
|
||||
// In-memory maps
|
||||
// ---------------------------
|
||||
const friendlyMembers = new Map(); // id -> { id, name, level, estimate, status, domElement }
|
||||
const enemyMembers = new Map();
|
||||
|
||||
// DOM references
|
||||
const friendlyContainer = document.getElementById("friendly-container");
|
||||
const enemyContainer = document.getElementById("enemy-container");
|
||||
|
||||
function createMemberCard(member) {
|
||||
// polling interval (ms) for assignments sync
|
||||
const ASSIGNMENTS_POLL_MS = 5000;
|
||||
|
||||
// utility
|
||||
function toInt(v) {
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Status CSS helpers
|
||||
// ---------------------------
|
||||
function statusClass(status) {
|
||||
if (!status) return "";
|
||||
const s = String(status).toLowerCase();
|
||||
if (s.includes("okay")) return "status-ok";
|
||||
if (s.includes("travel") || s.includes("abroad")) return "status-traveling";
|
||||
if (s.includes("hospital")) return "status-hospitalized";
|
||||
return "";
|
||||
}
|
||||
|
||||
function applyStatusClass(member) {
|
||||
const span = member.domElement?.querySelector(".status-text");
|
||||
if (!span) return;
|
||||
span.className = "status-text " + statusClass(member.status);
|
||||
span.textContent = member.status || "Unknown";
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Card creation & update
|
||||
// ---------------------------
|
||||
function createMemberCard(member, kind) {
|
||||
// if domElement already exists, reuse it
|
||||
if (member.domElement) return member.domElement;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.classList.add("member-card");
|
||||
card.setAttribute("draggable", "true");
|
||||
card.dataset.id = member.id;
|
||||
card.dataset.kind = kind;
|
||||
|
||||
// Clear innerHTML, create structured divs
|
||||
const nameDiv = document.createElement("div");
|
||||
nameDiv.classList.add("name");
|
||||
nameDiv.className = "name";
|
||||
nameDiv.textContent = member.name;
|
||||
nameDiv.style.color = kind === "friendly" ? "#8fd38f" : "#ff6b6b";
|
||||
|
||||
const statsDiv = document.createElement("div");
|
||||
statsDiv.classList.add("stats");
|
||||
statsDiv.className = "stats";
|
||||
statsDiv.innerHTML = `
|
||||
Level: ${member.level}<br>
|
||||
Estimate: ${member.estimate}<br>
|
||||
Lv: ${member.level} <br>
|
||||
Est: ${member.estimate} <br>
|
||||
Status: <span class="status-text">${member.status || "Unknown"}</span>
|
||||
`;
|
||||
|
||||
card.appendChild(nameDiv);
|
||||
card.appendChild(statsDiv);
|
||||
|
||||
// Store reference to DOM element
|
||||
// store reference
|
||||
member.domElement = card;
|
||||
|
||||
// Make card draggable
|
||||
card.draggable = true;
|
||||
card.addEventListener("dragstart", e => {
|
||||
e.dataTransfer.setData("text/plain", member.id);
|
||||
// drag handlers: payload is JSON string
|
||||
card.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.setData("text/plain", JSON.stringify({ kind, id: member.id }));
|
||||
card.style.opacity = "0.5";
|
||||
});
|
||||
card.addEventListener("dragend", () => {
|
||||
card.style.opacity = "1";
|
||||
});
|
||||
|
||||
// Set initial status color
|
||||
updateStatusColor(member);
|
||||
// apply initial status class
|
||||
applyStatusClass(member);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function updateStatusColor(member) {
|
||||
const statusSpan = member.domElement.querySelector(".status-text");
|
||||
statusSpan.classList.remove("status-ok", "status-traveling", "status-hospitalized");
|
||||
function updateMemberCard(member) {
|
||||
if (!member.domElement) return;
|
||||
// name might have changed (rare)
|
||||
const nameEl = member.domElement.querySelector(".name");
|
||||
if (nameEl && nameEl.textContent !== member.name) nameEl.textContent = member.name;
|
||||
|
||||
if (!member.status) return;
|
||||
|
||||
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);
|
||||
// stats/status
|
||||
const statsEl = member.domElement.querySelector(".stats");
|
||||
if (statsEl) {
|
||||
const statusSpan = statsEl.querySelector(".status-text");
|
||||
if (statusSpan) {
|
||||
statusSpan.textContent = member.status || "Unknown";
|
||||
statusSpan.className = "status-text " + statusClass(member.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function populateFriendly() {
|
||||
const id = parseInt(document.getElementById("friendly-id").value);
|
||||
if (!id) return alert("Enter a valid faction ID!");
|
||||
// ---------------------------
|
||||
// Load members from server
|
||||
// ---------------------------
|
||||
async function loadMembers(kind) {
|
||||
const url = kind === "friendly" ? "/api/friendly_members" : "/api/enemy_members";
|
||||
const container = kind === "friendly" ? friendlyContainer : enemyContainer;
|
||||
const map = kind === "friendly" ? friendlyMembers : enemyMembers;
|
||||
|
||||
await fetch("/api/populate_friendly", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ faction_id: id, interval: 0 })
|
||||
try {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
console.error("Failed to load members:", res.status);
|
||||
return;
|
||||
}
|
||||
const list = await res.json();
|
||||
|
||||
// build set of received ids
|
||||
const receivedIds = new Set(list.map(m => m.id));
|
||||
|
||||
// update or create
|
||||
for (const m of list) {
|
||||
let existing = map.get(m.id);
|
||||
if (existing) {
|
||||
// update fields (preserve dom & placement)
|
||||
existing.name = m.name;
|
||||
existing.level = m.level;
|
||||
existing.estimate = m.estimate;
|
||||
// keep existing.status if already set (status refreshes separately)
|
||||
if (!existing.status) existing.status = "Unknown";
|
||||
updateMemberCard(existing);
|
||||
} else {
|
||||
const newMember = {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
level: m.level,
|
||||
estimate: m.estimate,
|
||||
status: m.status || "Unknown",
|
||||
domElement: null
|
||||
};
|
||||
map.set(m.id, newMember);
|
||||
// create card but DO NOT automatically append here;
|
||||
// placement will be handled by loadAssignmentsFromServer()
|
||||
createMemberCard(newMember, kind);
|
||||
}
|
||||
}
|
||||
|
||||
// remove obsolete members
|
||||
for (const existingId of Array.from(map.keys())) {
|
||||
if (!receivedIds.has(existingId)) {
|
||||
const member = map.get(existingId);
|
||||
if (member?.domElement?.parentElement) member.domElement.parentElement.removeChild(member.domElement);
|
||||
map.delete(existingId);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("loadMembers error", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Server-side assignments API interactions
|
||||
// ---------------------------
|
||||
async function fetchAssignments() {
|
||||
try {
|
||||
const res = await fetch("/api/assignments", { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
console.warn("Failed to fetch assignments:", res.status);
|
||||
return null;
|
||||
}
|
||||
return await res.json(); // expected { "1": { friendly: [...], enemy: [...] }, ... }
|
||||
} catch (err) {
|
||||
console.error("fetchAssignments error", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAssignmentToServer(groupId, kind, memberId) {
|
||||
try {
|
||||
const res = await fetch("/api/assign_member", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ group_id: groupId, kind, member_id: memberId })
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn("Failed to save assignment:", res.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("saveAssignmentToServer error", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAssignmentFromServer(memberId) {
|
||||
try {
|
||||
const res = await fetch("/api/remove_member_assignment", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ member_id: memberId })
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn("Failed to remove assignment:", res.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("removeAssignmentFromServer error", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAssignmentsOnServer() {
|
||||
try {
|
||||
const res = await fetch("/api/clear_assignments", { method: "POST" });
|
||||
if (!res.ok) console.warn("clear_assignments failed:", res.status);
|
||||
} catch (err) {
|
||||
console.error("clearAssignmentsOnServer error", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Apply server assignments to DOM
|
||||
// ---------------------------
|
||||
function ensureMainListContains(member, kind) {
|
||||
const container = kind === "friendly" ? friendlyContainer : enemyContainer;
|
||||
const parent = member.domElement?.parentElement;
|
||||
|
||||
// Detect group zone: ids like "group-1-friendly" or "group-2-enemy"
|
||||
const isInGroupZone = parent && typeof parent.id === "string" && /^group-\d+-/.test(parent.id);
|
||||
|
||||
// If member has no parent yet, or is in a group zone, ensure it ends up in the main list
|
||||
if (!parent || isInGroupZone) {
|
||||
// If it's already in the right container, nothing to do
|
||||
if (member.domElement && member.domElement.parentElement !== container) {
|
||||
// remove from previous parent (if any) and append to main list
|
||||
const prev = member.domElement.parentElement;
|
||||
if (prev) prev.removeChild(member.domElement);
|
||||
container.appendChild(member.domElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function applyAssignmentsToDOM(assignments) {
|
||||
if (!assignments) return;
|
||||
|
||||
// First, move all assigned members into their group zones
|
||||
Object.keys(assignments).forEach(groupKey => {
|
||||
const group = assignments[groupKey];
|
||||
// friendly list
|
||||
(group.friendly || []).forEach(id => {
|
||||
const member = friendlyMembers.get(id);
|
||||
if (!member) return; // not loaded yet
|
||||
const zoneId = `group-${groupKey}-friendly`;
|
||||
const zone = document.getElementById(zoneId);
|
||||
if (!zone) return;
|
||||
if (member.domElement && member.domElement.parentElement !== zone) {
|
||||
// remove from previous parent
|
||||
const prev = member.domElement.parentElement;
|
||||
if (prev) prev.removeChild(member.domElement);
|
||||
zone.appendChild(member.domElement);
|
||||
}
|
||||
});
|
||||
// enemy list
|
||||
(group.enemy || []).forEach(id => {
|
||||
const member = enemyMembers.get(id);
|
||||
if (!member) return;
|
||||
const zoneId = `group-${groupKey}-enemy`;
|
||||
const zone = document.getElementById(zoneId);
|
||||
if (!zone) return;
|
||||
if (member.domElement && member.domElement.parentElement !== zone) {
|
||||
const prev = member.domElement.parentElement;
|
||||
if (prev) prev.removeChild(member.domElement);
|
||||
zone.appendChild(member.domElement);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
loadMembers("friendly");
|
||||
// Second, ensure all unassigned members are in their main lists
|
||||
friendlyMembers.forEach(member => {
|
||||
// check if member is referenced in assignments anywhere
|
||||
const assigned = Object.values(assignments).some(g => (g.friendly || []).includes(member.id));
|
||||
if (!assigned) ensureMainListContains(member, "friendly");
|
||||
});
|
||||
enemyMembers.forEach(member => {
|
||||
const assigned = Object.values(assignments).some(g => (g.enemy || []).includes(member.id));
|
||||
if (!assigned) ensureMainListContains(member, "enemy");
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Polling assignments loop
|
||||
// ---------------------------
|
||||
let assignmentsPollHandle = null;
|
||||
let lastAssignmentsSnapshot = null;
|
||||
|
||||
async function pollAssignments() {
|
||||
const assignments = await fetchAssignments();
|
||||
if (!assignments) return;
|
||||
|
||||
// quick shallow compare to avoid full DOM updates if nothing changed
|
||||
const snapshot = JSON.stringify(assignments);
|
||||
if (snapshot !== lastAssignmentsSnapshot) {
|
||||
lastAssignmentsSnapshot = snapshot;
|
||||
applyAssignmentsToDOM(assignments);
|
||||
}
|
||||
}
|
||||
|
||||
function startAssignmentsPolling() {
|
||||
if (assignmentsPollHandle) clearInterval(assignmentsPollHandle);
|
||||
assignmentsPollHandle = setInterval(pollAssignments, ASSIGNMENTS_POLL_MS);
|
||||
// run immediately
|
||||
pollAssignments();
|
||||
}
|
||||
|
||||
function stopAssignmentsPolling() {
|
||||
if (assignmentsPollHandle) {
|
||||
clearInterval(assignmentsPollHandle);
|
||||
assignmentsPollHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Dropzone wiring (sends assignment to server)
|
||||
// ---------------------------
|
||||
function setupDropZones() {
|
||||
// attach handlers once
|
||||
const zones = document.querySelectorAll(".drop-zone, .member-list");
|
||||
zones.forEach(zone => {
|
||||
// avoid double-binding by using a marker
|
||||
if (zone.dataset._drag_listeners_attached) return;
|
||||
|
||||
zone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
// peek at payload to check validity
|
||||
let raw = null;
|
||||
try { raw = e.dataTransfer.getData("text/plain"); } catch {}
|
||||
let valid = false;
|
||||
if (raw) {
|
||||
try {
|
||||
const p = JSON.parse(raw);
|
||||
const kind = p.kind;
|
||||
const zoneType = zone.classList.contains("friendly-zone") ? "friendly" :
|
||||
zone.classList.contains("enemy-zone") ? "enemy" :
|
||||
zone.classList.contains("member-list") || zone.id === "friendly-container" || zone.id === "enemy-container"
|
||||
? "main-list" : null;
|
||||
// main-list accepts only same-kind cards
|
||||
if (zoneType === "main-list") valid = true;
|
||||
else if (zoneType && kind === zoneType) valid = true;
|
||||
} catch {}
|
||||
}
|
||||
zone.classList.toggle("dragover-valid", valid);
|
||||
zone.classList.toggle("dragover-invalid", !valid);
|
||||
});
|
||||
|
||||
zone.addEventListener("dragleave", () => {
|
||||
zone.classList.remove("dragover-valid", "dragover-invalid");
|
||||
});
|
||||
|
||||
zone.addEventListener("drop", async (e) => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove("dragover-valid", "dragover-invalid");
|
||||
|
||||
let payload;
|
||||
try { payload = JSON.parse(e.dataTransfer.getData("text/plain")); } catch { return; }
|
||||
if (!payload || !payload.kind || !payload.id) return;
|
||||
|
||||
const kind = payload.kind;
|
||||
const id = payload.id;
|
||||
|
||||
const zoneIsFriendly = zone.classList.contains("friendly-zone");
|
||||
const zoneIsEnemy = zone.classList.contains("enemy-zone");
|
||||
const zoneIsMainList = zone.classList.contains("member-list") || zone.id === "friendly-container" || zone.id === "enemy-container";
|
||||
|
||||
// Validate mix of kinds
|
||||
if (zoneIsFriendly && kind !== "friendly") {
|
||||
// invalid drop
|
||||
alert("Cannot drop enemy into friendly zone.");
|
||||
return;
|
||||
}
|
||||
if (zoneIsEnemy && kind !== "enemy") {
|
||||
alert("Cannot drop friendly into enemy zone.");
|
||||
return;
|
||||
}
|
||||
|
||||
// If dropping into a group zone -> save assignment to server
|
||||
if (zoneIsFriendly || zoneIsEnemy) {
|
||||
// zone id format expected: group-<groupKey>-friendly or group-<groupKey>-enemy
|
||||
const parts = zone.id.split("-");
|
||||
// expected ["group", "<groupKey>", "friendly"]
|
||||
if (parts.length >= 3) {
|
||||
const groupKey = parts[1];
|
||||
await saveAssignmentToServer(groupKey, kind, id);
|
||||
// Move in DOM (we still move locally so UX is instant)
|
||||
const map = kind === "friendly" ? friendlyMembers : enemyMembers;
|
||||
const member = map.get(id);
|
||||
if (member && member.domElement && member.domElement.parentElement !== zone) {
|
||||
const prev = member.domElement.parentElement;
|
||||
if (prev) prev.removeChild(member.domElement);
|
||||
zone.appendChild(member.domElement);
|
||||
}
|
||||
} else {
|
||||
console.warn("Unexpected zone id format", zone.id);
|
||||
}
|
||||
} else if (zoneIsMainList) {
|
||||
// Dropped back into main list - remove assignment on server
|
||||
await removeAssignmentFromServer(id);
|
||||
// Move card back to main list container
|
||||
const map = kind === "friendly" ? friendlyMembers : enemyMembers;
|
||||
const member = map.get(id);
|
||||
const container = kind === "friendly" ? friendlyContainer : enemyContainer;
|
||||
if (member && member.domElement && member.domElement.parentElement !== container) {
|
||||
const prev = member.domElement.parentElement;
|
||||
if (prev) prev.removeChild(member.domElement);
|
||||
container.appendChild(member.domElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
zone.dataset._drag_listeners_attached = "1";
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Populate & Status functions
|
||||
// ---------------------------
|
||||
async function populateFriendly() {
|
||||
const id = toInt(document.getElementById("friendly-id").value);
|
||||
if (!id) return alert("Enter Friendly Faction ID");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/populate_friendly", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ faction_id: id, interval: 0 })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
// Update in-memory map & DOM
|
||||
if (data.members) {
|
||||
for (const m of data.members) {
|
||||
let existing = friendlyMembers.get(m.id);
|
||||
if (existing) {
|
||||
existing.name = m.name;
|
||||
existing.level = m.level;
|
||||
existing.estimate = m.estimate;
|
||||
existing.status = m.status || "Unknown";
|
||||
updateMemberCard(existing);
|
||||
} else {
|
||||
const newMember = {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
level: m.level,
|
||||
estimate: m.estimate,
|
||||
status: m.status || "Unknown",
|
||||
domElement: null
|
||||
};
|
||||
friendlyMembers.set(m.id, newMember);
|
||||
const card = createMemberCard(newMember, "friendly");
|
||||
friendlyContainer.appendChild(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh assignments & status UI
|
||||
await loadMembers("enemy"); // in case population changed cross lists
|
||||
await pollAssignments();
|
||||
} catch (err) {
|
||||
console.error("populateFriendly error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function populateEnemy() {
|
||||
const id = parseInt(document.getElementById("enemy-id").value);
|
||||
if (!id) return alert("Enter a valid faction ID!");
|
||||
const id = toInt(document.getElementById("enemy-id").value);
|
||||
if (!id) return alert("Enter Enemy Faction ID");
|
||||
|
||||
await fetch("/api/populate_enemy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ faction_id: id, interval: 0 })
|
||||
});
|
||||
try {
|
||||
const res = await fetch("/api/populate_enemy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ faction_id: id, interval: 0 })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
loadMembers("enemy");
|
||||
if (data.members) {
|
||||
for (const m of data.members) {
|
||||
let existing = enemyMembers.get(m.id);
|
||||
if (existing) {
|
||||
existing.name = m.name;
|
||||
existing.level = m.level;
|
||||
existing.estimate = m.estimate;
|
||||
existing.status = m.status || "Unknown";
|
||||
updateMemberCard(existing);
|
||||
} else {
|
||||
const newMember = {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
level: m.level,
|
||||
estimate: m.estimate,
|
||||
status: m.status || "Unknown",
|
||||
domElement: null
|
||||
};
|
||||
enemyMembers.set(m.id, newMember);
|
||||
const card = createMemberCard(newMember, "enemy");
|
||||
enemyContainer.appendChild(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh assignments & status UI
|
||||
await loadMembers("friendly");
|
||||
await pollAssignments();
|
||||
} catch (err) {
|
||||
console.error("populateEnemy error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function startFriendlyStatus() {
|
||||
const id = parseInt(document.getElementById("friendly-id").value);
|
||||
const interval = parseInt(document.getElementById("refresh-interval").value) || 10;
|
||||
// status refresh (pulls from server status json)
|
||||
async function refreshStatus(kind) {
|
||||
const url = kind === "friendly" ? "/api/friendly_status" : "/api/enemy_status";
|
||||
const map = kind === "friendly" ? friendlyMembers : enemyMembers;
|
||||
try {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const statusData = await res.json();
|
||||
for (const idStr of Object.keys(statusData)) {
|
||||
const id = parseInt(idStr);
|
||||
const member = map.get(id);
|
||||
if (!member) continue;
|
||||
member.status = statusData[idStr].status;
|
||||
updateMemberCard(member);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("refreshStatus error", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Start/Stop status loops (buttons toggle)
|
||||
let friendlyStatusIntervalHandle = null;
|
||||
let enemyStatusIntervalHandle = null;
|
||||
|
||||
async function toggleFriendlyStatus() {
|
||||
const btn = document.getElementById("friendly-status-btn");
|
||||
if (friendlyStatusIntervalHandle) {
|
||||
clearInterval(friendlyStatusIntervalHandle);
|
||||
friendlyStatusIntervalHandle = null;
|
||||
btn.textContent = "Start Refresh";
|
||||
return;
|
||||
}
|
||||
|
||||
const id = toInt(document.getElementById("friendly-id").value);
|
||||
const interval = Math.max(1, toInt(document.getElementById("refresh-interval").value) || 10);
|
||||
|
||||
await fetch("/api/start_friendly_status", {
|
||||
method: "POST",
|
||||
@@ -136,12 +560,22 @@ async function startFriendlyStatus() {
|
||||
body: JSON.stringify({ faction_id: id, interval })
|
||||
});
|
||||
|
||||
setInterval(() => refreshStatus("friendly"), interval * 1000);
|
||||
friendlyStatusIntervalHandle = setInterval(() => refreshStatus("friendly"), interval * 1000);
|
||||
refreshStatus("friendly");
|
||||
btn.textContent = "Stop Refresh";
|
||||
}
|
||||
|
||||
async function startEnemyStatus() {
|
||||
const id = parseInt(document.getElementById("enemy-id").value);
|
||||
const interval = parseInt(document.getElementById("refresh-interval").value) || 10;
|
||||
async function toggleEnemyStatus() {
|
||||
const btn = document.getElementById("enemy-status-btn");
|
||||
if (enemyStatusIntervalHandle) {
|
||||
clearInterval(enemyStatusIntervalHandle);
|
||||
enemyStatusIntervalHandle = null;
|
||||
btn.textContent = "Start Refresh";
|
||||
return;
|
||||
}
|
||||
|
||||
const id = toInt(document.getElementById("enemy-id").value);
|
||||
const interval = Math.max(1, toInt(document.getElementById("refresh-interval").value) || 10);
|
||||
|
||||
await fetch("/api/start_enemy_status", {
|
||||
method: "POST",
|
||||
@@ -149,10 +583,51 @@ async function startEnemyStatus() {
|
||||
body: JSON.stringify({ faction_id: id, interval })
|
||||
});
|
||||
|
||||
setInterval(() => refreshStatus("enemy"), interval * 1000);
|
||||
enemyStatusIntervalHandle = setInterval(() => refreshStatus("enemy"), interval * 1000);
|
||||
refreshStatus("enemy");
|
||||
btn.textContent = "Stop Refresh";
|
||||
}
|
||||
|
||||
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);
|
||||
// ---------------------------
|
||||
// Reset groups (server-side)
|
||||
// ---------------------------
|
||||
async function resetGroups() {
|
||||
if (!confirm("Reset all group assignments on the server?")) return;
|
||||
await clearAssignmentsOnServer();
|
||||
// reload assignments & UI
|
||||
await pollAssignments();
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Wire up buttons & init
|
||||
// ---------------------------
|
||||
function wireUp() {
|
||||
document.getElementById("friendly-populate-btn").addEventListener("click", populateFriendly);
|
||||
document.getElementById("enemy-populate-btn").addEventListener("click", populateEnemy);
|
||||
document.getElementById("friendly-status-btn").addEventListener("click", toggleFriendlyStatus);
|
||||
document.getElementById("enemy-status-btn").addEventListener("click", toggleEnemyStatus);
|
||||
const resetBtn = document.getElementById("reset-groups-btn");
|
||||
if (resetBtn) resetBtn.addEventListener("click", resetGroups);
|
||||
|
||||
setupDropZones();
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Initial load
|
||||
// ---------------------------
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
wireUp();
|
||||
|
||||
// load members first
|
||||
await loadMembers("friendly");
|
||||
await loadMembers("enemy");
|
||||
|
||||
// load assignments and apply them
|
||||
await pollAssignments();
|
||||
startAssignmentsPolling();
|
||||
|
||||
// kick off status polling for initial UI (but status loops are triggered by Start Refresh buttons)
|
||||
// immediate status pull so cards show current status
|
||||
await refreshStatus("friendly");
|
||||
await refreshStatus("enemy");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* --- base --- */
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #1e1e2f;
|
||||
@@ -8,165 +9,195 @@ body {
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 95%;
|
||||
max-width: 1300px;
|
||||
margin: 2rem auto;
|
||||
width: 96%;
|
||||
max-width: 1400px;
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
|
||||
/* top bar */
|
||||
.top-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.interval-box {
|
||||
background-color: #3a3a4d;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.interval-box label { color: #ffcc66; font-size: 0.9rem; margin-right: 8px; }
|
||||
.interval-box input { width: 80px; padding: 6px; border-radius: 6px; border: none; }
|
||||
|
||||
.interval-box label {
|
||||
font-size: 0.9rem;
|
||||
color: #ffcc66;
|
||||
}
|
||||
|
||||
.interval-box input {
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.faction-row {
|
||||
/* main split */
|
||||
.main-row {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.faction-card {
|
||||
flex: 1;
|
||||
background-color: #2c2c3e;
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.5);
|
||||
/* left column: stacked friendly / enemy */
|
||||
.left-col {
|
||||
width: 48%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.faction-card h2 {
|
||||
color: #66ccff;
|
||||
/* right column: groups grid */
|
||||
.right-col {
|
||||
width: 52%;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
.groups-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* group card */
|
||||
.group {
|
||||
background: #232331;
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem;
|
||||
min-height: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-weight: bold;
|
||||
color: #ffcc66;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* zones layout inside group */
|
||||
.group-zones {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* drop zones */
|
||||
.drop-zone {
|
||||
flex: 1;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.02));
|
||||
border-radius: 8px;
|
||||
border: 2px dashed rgba(255,255,255,0.03);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: stretch;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* subtle label */
|
||||
.zone-label {
|
||||
font-size: 0.9rem;
|
||||
color: #99a7bf;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* highlight when a valid draggable is over */
|
||||
.drop-zone.dragover-valid {
|
||||
background: rgba(51,153,255,0.06);
|
||||
border-color: rgba(102,204,255,0.4);
|
||||
}
|
||||
|
||||
/* invalid dragover */
|
||||
.drop-zone.dragover-invalid {
|
||||
background: rgba(255,77,77,0.06);
|
||||
border-color: rgba(255,77,77,0.4);
|
||||
}
|
||||
|
||||
/* friendly/enemy specific coloring for zone headers */
|
||||
.friendly-zone .zone-label { color: #8fd38f; }
|
||||
.enemy-zone .zone-label { color: #ff9b9b; }
|
||||
|
||||
/* Faction card containers on left */
|
||||
.faction-card.small {
|
||||
background-color: #2c2c3e;
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 18px rgba(0,0,0,0.45);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.faction-card.small h2 { color: #66ccff; margin: 0; }
|
||||
.faction-card .controls { display:flex; gap: 0.5rem; align-items:center; margin-bottom: 6px; }
|
||||
.faction-card .controls input { padding: 0.5rem; border-radius:6px; border: none; }
|
||||
|
||||
/* member list in left column */
|
||||
.member-list {
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
background: #1a1a26;
|
||||
padding: 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.02);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* member card (used both in lists and zones) */
|
||||
.member-card {
|
||||
background-color: #3a3a4d;
|
||||
color: #f0f0f0;
|
||||
padding: 0.7rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.45);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* name and stat blocks */
|
||||
.member-card .name { min-width: 110px; color: #66ccff; font-weight: bold; }
|
||||
.member-card .stats { color: #f0f0f0; font-size: 0.9rem; line-height: 1.2; }
|
||||
|
||||
/* Friendly name color */
|
||||
.member-card.friendly .name {
|
||||
color: #4cff4c; /* Green */
|
||||
}
|
||||
|
||||
/* Enemy name color */
|
||||
.member-card.enemy .name {
|
||||
color: #ff4c4c; /* Red */
|
||||
}
|
||||
|
||||
|
||||
/* small status span; color is applied by classes */
|
||||
.status-text { font-weight: 700; padding-left: 6px; }
|
||||
|
||||
/* status color classes */
|
||||
.status-ok { color: #28a745; text-shadow: 0 0 2px rgba(40,167,69,0.25); }
|
||||
.status-traveling { color: #3399ff; text-shadow: 0 0 2px rgba(51,153,255,0.25); }
|
||||
.status-hospitalized { color: #ff4d4d; text-shadow: 0 0 2px rgba(255,77,77,0.25); }
|
||||
|
||||
/* buttons */
|
||||
button {
|
||||
padding: 0.7rem 1rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background-color: #66ccff;
|
||||
color: #1e1e2f;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
button:hover { background-color: #3399ff; }
|
||||
|
||||
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; }
|
||||
/* scrollbar niceties for drop zones and lists */
|
||||
.member-list::-webkit-scrollbar, .drop-zone::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
.member-list::-webkit-scrollbar-thumb, .drop-zone::-webkit-scrollbar-thumb { background: #66ccff; border-radius: 4px; }
|
||||
|
||||
@@ -1,39 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta charset="UTF-8" />
|
||||
<title>War Dashboard</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Top bar: Title + Refresh Interval -->
|
||||
|
||||
<!-- Top bar: Title + interval + Reset button -->
|
||||
<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 class="top-controls">
|
||||
<button id="reset-groups-btn" class="reset-btn">Reset Groups</button>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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 class="main-row">
|
||||
|
||||
<!-- LEFT COLUMN -->
|
||||
<div class="left-col">
|
||||
|
||||
<!-- FRIENDLY -->
|
||||
<div class="faction-card small">
|
||||
<h2>Friendly Faction</h2>
|
||||
<div class="controls">
|
||||
<input type="number" id="friendly-id" placeholder="Faction ID" />
|
||||
<button id="friendly-populate-btn">Populate</button>
|
||||
<button id="friendly-status-btn">Start Refresh</button>
|
||||
</div>
|
||||
|
||||
<div id="friendly-container"
|
||||
class="member-list friendly-zone"
|
||||
aria-label="Friendly members"></div>
|
||||
</div>
|
||||
|
||||
<!-- ENEMY -->
|
||||
<div class="faction-card small">
|
||||
<h2>Enemy Faction</h2>
|
||||
<div class="controls">
|
||||
<input type="number" id="enemy-id" placeholder="Faction ID" />
|
||||
<button id="enemy-populate-btn">Populate</button>
|
||||
<button id="enemy-status-btn">Start Refresh</button>
|
||||
</div>
|
||||
|
||||
<div id="enemy-container"
|
||||
class="member-list enemy-zone"
|
||||
aria-label="Enemy members"></div>
|
||||
</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>
|
||||
<!-- RIGHT COLUMN: BATTLE GROUPS -->
|
||||
<div class="right-col">
|
||||
<div class="groups-grid">
|
||||
|
||||
<!-- Group 1 -->
|
||||
<div class="group" id="group-1" data-id="1">
|
||||
<div class="group-title">Group 1</div>
|
||||
<div class="group-zones">
|
||||
<div class="drop-zone friendly-zone" data-zone="friendly" data-group="1" id="group-1-friendly">
|
||||
<div class="zone-label">Friendly</div>
|
||||
</div>
|
||||
<div class="drop-zone enemy-zone" data-zone="enemy" data-group="1" id="group-1-enemy">
|
||||
<div class="zone-label">Enemy</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group 2 -->
|
||||
<div class="group" id="group-2" data-id="2">
|
||||
<div class="group-title">Group 2</div>
|
||||
<div class="group-zones">
|
||||
<div class="drop-zone friendly-zone" data-zone="friendly" data-group="2" id="group-2-friendly">
|
||||
<div class="zone-label">Friendly</div>
|
||||
</div>
|
||||
<div class="drop-zone enemy-zone" data-zone="enemy" data-group="2" id="group-2-enemy">
|
||||
<div class="zone-label">Enemy</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group 3 -->
|
||||
<div class="group" id="group-3" data-id="3">
|
||||
<div class="group-title">Group 3</div>
|
||||
<div class="group-zones">
|
||||
<div class="drop-zone friendly-zone" data-zone="friendly" data-group="3" id="group-3-friendly">
|
||||
<div class="zone-label">Friendly</div>
|
||||
</div>
|
||||
<div class="drop-zone enemy-zone" data-zone="enemy" data-group="3" id="group-3-enemy">
|
||||
<div class="zone-label">Enemy</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group 4 -->
|
||||
<div class="group" id="group-4" data-id="4">
|
||||
<div class="group-title">Group 4</div>
|
||||
<div class="group-zones">
|
||||
<div class="drop-zone friendly-zone" data-zone="friendly" data-group="4" id="group-4-friendly">
|
||||
<div class="zone-label">Friendly</div>
|
||||
</div>
|
||||
<div class="drop-zone enemy-zone" data-zone="enemy" data-group="4" id="group-4-enemy">
|
||||
<div class="zone-label">Enemy</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group 5 -->
|
||||
<div class="group" id="group-5" data-id="5">
|
||||
<div class="group-title">Group 5</div>
|
||||
<div class="group-zones">
|
||||
<div class="drop-zone friendly-zone" data-zone="friendly" data-group="5" id="group-5-friendly">
|
||||
<div class="zone-label">Friendly</div>
|
||||
</div>
|
||||
<div class="drop-zone enemy-zone" data-zone="enemy" data-group="5" id="group-5-enemy">
|
||||
<div class="zone-label">Enemy</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user