Persistent server-side group assignments
This commit is contained in:
@@ -15,3 +15,6 @@ ToDo:
|
|||||||
- have multiple managers logged in to make changes
|
- have multiple managers logged in to make changes
|
||||||
- basic auth
|
- basic auth
|
||||||
- since control of the Discord bot would also be through there technically
|
- 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.
172
main.py
172
main.py
@@ -1,3 +1,4 @@
|
|||||||
|
# main.py (updated)
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
from config import ALLOWED_CHANNEL_ID, HIT_CHECK_INTERVAL, REASSIGN_DELAY
|
from config import ALLOWED_CHANNEL_ID, HIT_CHECK_INTERVAL, REASSIGN_DELAY
|
||||||
@@ -8,62 +9,94 @@ import asyncio
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
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.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pydantic import BaseModel
|
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
|
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 Webpage
|
# 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 POST input
|
# Pydantic models for JSON POST input
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
class FactionRequest(BaseModel):
|
class FactionRequest(BaseModel):
|
||||||
faction_id: int
|
faction_id: int
|
||||||
interval: int = 30
|
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)
|
# Populate endpoints (populate JSON once)
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
@app.post("/api/populate_friendly")
|
@app.post("/api/populate_friendly")
|
||||||
async def api_populate_friendly(data: FactionRequest):
|
async def api_populate_friendly(data: FactionRequest):
|
||||||
from services.torn_api import populate_friendly
|
# call the Torn populater (should write data/friendly_faction.json)
|
||||||
await populate_friendly(data.faction_id)
|
await populate_friendly(data.faction_id)
|
||||||
|
# sync STATE from file
|
||||||
|
await sync_state_from_file(Path("data/friendly_faction.json"), "friendly")
|
||||||
return {"status": "friendly populated", "id": data.faction_id}
|
return {"status": "friendly populated", "id": data.faction_id}
|
||||||
|
|
||||||
@app.post("/api/populate_enemy")
|
@app.post("/api/populate_enemy")
|
||||||
async def api_populate_enemy(data: FactionRequest):
|
async def api_populate_enemy(data: FactionRequest):
|
||||||
from services.torn_api import populate_enemy
|
|
||||||
await populate_enemy(data.faction_id)
|
await populate_enemy(data.faction_id)
|
||||||
|
await sync_state_from_file(Path("data/enemy_faction.json"), "enemy")
|
||||||
return {"status": "enemy populated", "id": data.faction_id}
|
return {"status": "enemy populated", "id": data.faction_id}
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Start status refresh loops
|
# Start status refresh loops
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
@@ -79,29 +112,27 @@ async def api_start_enemy_status(data: FactionRequest):
|
|||||||
await start_enemy_status_loop(data.faction_id, data.interval)
|
await start_enemy_status_loop(data.faction_id, data.interval)
|
||||||
return {"status": "enemy status loop started", "id": data.faction_id, "interval": 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")
|
@app.get("/api/friendly_members")
|
||||||
async def get_friendly_members():
|
async def get_friendly_members():
|
||||||
|
# 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_faction.json")
|
path = Path("data/friendly_faction.json")
|
||||||
if not path.exists():
|
return _load_json_list(path)
|
||||||
return []
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
@app.get("/api/enemy_members")
|
@app.get("/api/enemy_members")
|
||||||
async def get_enemy_members():
|
async def get_enemy_members():
|
||||||
|
if STATE.enemy:
|
||||||
|
return [m.model_dump() for m in STATE.enemy.values()]
|
||||||
path = Path("data/enemy_faction.json")
|
path = Path("data/enemy_faction.json")
|
||||||
if not path.exists():
|
return _load_json_list(path)
|
||||||
return []
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================
|
# =============================
|
||||||
# Status JSON endpoints
|
# Status JSON endpoints (unchanged)
|
||||||
# =============================
|
# =============================
|
||||||
@app.get("/api/friendly_status")
|
@app.get("/api/friendly_status")
|
||||||
async def api_friendly_status():
|
async def api_friendly_status():
|
||||||
@@ -111,7 +142,6 @@ async def api_friendly_status():
|
|||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/enemy_status")
|
@app.get("/api/enemy_status")
|
||||||
async def api_enemy_status():
|
async def api_enemy_status():
|
||||||
path = Path("data/enemy_status.json")
|
path = Path("data/enemy_status.json")
|
||||||
@@ -120,12 +150,95 @@ async def api_enemy_status():
|
|||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
return json.load(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_faction.json") if kind == "friendly" else Path("data/enemy_faction.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
|
# Discord Bot Setup
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
intents = discord.Intents.default()
|
intents = discord.Intents.default()
|
||||||
intents.message_content = True
|
intents.message_content = True
|
||||||
|
|
||||||
@@ -134,7 +247,6 @@ 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):
|
||||||
await self.add_cog(
|
await self.add_cog(
|
||||||
@@ -159,26 +271,20 @@ 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 Point
|
# Main Entry Point
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
loop = asyncio.get_event_loop()
|
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.
139
services/server_state.py
Normal file
139
services/server_state.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# 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 # "group1", "group2", ... or None
|
||||||
|
hits: int = 0
|
||||||
|
|
||||||
|
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 (all use the lock when called from async endpoints)
|
||||||
|
async def assign_member(self, member_id: int, kind: str, group_key: str):
|
||||||
|
"""
|
||||||
|
Assign a member (by id) of kind ('friendly'|'enemy') to group_key ("1".."N").
|
||||||
|
This removes it from any previous group of the same kind.
|
||||||
|
"""
|
||||||
|
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 not present
|
||||||
|
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):
|
||||||
|
"""Remove member from any group (both friendly and enemy)."""
|
||||||
|
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 attr if member in maps
|
||||||
|
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):
|
||||||
|
"""Clear all group lists and member.group fields and save to disk."""
|
||||||
|
async with self.lock:
|
||||||
|
# clear in-memory groups
|
||||||
|
for gk in self.groups:
|
||||||
|
self.groups[gk]["friendly"].clear()
|
||||||
|
self.groups[gk]["enemy"].clear()
|
||||||
|
|
||||||
|
# clear group for known members
|
||||||
|
for m in self.friendly.values():
|
||||||
|
m.group = None
|
||||||
|
for m in self.enemy.values():
|
||||||
|
m.group = None
|
||||||
|
|
||||||
|
# save to disk so clients get empty state
|
||||||
|
self.save_assignments()
|
||||||
|
|
||||||
|
async def get_assignments_snapshot(self) -> Dict[str, Dict[str, List[int]]]:
|
||||||
|
"""Return a copy snapshot of the groups dictionary."""
|
||||||
|
async with self.lock:
|
||||||
|
# shallow copy of structure
|
||||||
|
snap = {}
|
||||||
|
for gk, buckets in self.groups.items():
|
||||||
|
snap[gk] = {
|
||||||
|
"friendly": list(buckets["friendly"]),
|
||||||
|
"enemy": list(buckets["enemy"])
|
||||||
|
}
|
||||||
|
return snap
|
||||||
|
|
||||||
|
# member add/update helpers
|
||||||
|
async def upsert_member(self, member_data: dict, kind: str):
|
||||||
|
"""
|
||||||
|
Insert or update member. member_data expected to have id,name,level,estimate.
|
||||||
|
kind must be 'friendly' or 'enemy'.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
coll[mid] = member
|
||||||
|
|
||||||
|
async def remove_missing_members(self, received_ids: List[int], kind: str):
|
||||||
|
"""Remove any existing members not present in received_ids (useful after a populate)."""
|
||||||
|
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:
|
||||||
|
# remove from groups too
|
||||||
|
await self.remove_member_assignment(mid)
|
||||||
|
del coll[mid]
|
||||||
|
|
||||||
|
# single global state (5 groups)
|
||||||
|
STATE = ServerState(group_count=5)
|
||||||
@@ -1,52 +1,54 @@
|
|||||||
// dashboard.js (Corrected Full Version)
|
// dashboard.js (Server-side assignments, polling-mode)
|
||||||
// Prevents duplicates on populate
|
// - No more localStorage
|
||||||
// Preserves group placements
|
// - Polls /api/assignments every 5s to keep all clients in sync
|
||||||
// Updates or creates cards (never recreates all)
|
// - POST /api/assign_member on drop, POST /api/remove_member_assignment when returned to list
|
||||||
// Status refresh works correctly
|
// - Prevents duplicates, preserves card objects and status updates
|
||||||
|
|
||||||
//-----------------------------------------------------
|
// ---------------------------
|
||||||
// Maps of members (id -> member object)
|
// In-memory maps
|
||||||
//-----------------------------------------------------
|
// ---------------------------
|
||||||
const friendlyMembers = new Map();
|
const friendlyMembers = new Map(); // id -> { id, name, level, estimate, status, domElement }
|
||||||
const enemyMembers = new Map();
|
const enemyMembers = new Map();
|
||||||
|
|
||||||
// DOM containers
|
// DOM references
|
||||||
const friendlyContainer = document.getElementById("friendly-container");
|
const friendlyContainer = document.getElementById("friendly-container");
|
||||||
const enemyContainer = document.getElementById("enemy-container");
|
const enemyContainer = document.getElementById("enemy-container");
|
||||||
|
|
||||||
|
// polling interval (ms) for assignments sync
|
||||||
|
const ASSIGNMENTS_POLL_MS = 5000;
|
||||||
|
|
||||||
|
// utility
|
||||||
function toInt(v) {
|
function toInt(v) {
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isNaN(n) ? null : n;
|
return Number.isNaN(n) ? null : n;
|
||||||
}
|
}
|
||||||
|
|
||||||
//-----------------------------------------------------
|
// ---------------------------
|
||||||
// Status CSS assignment
|
// Status CSS helpers
|
||||||
//-----------------------------------------------------
|
// ---------------------------
|
||||||
function applyStatusClass(member) {
|
|
||||||
const span = member.domElement?.querySelector(".status-text");
|
|
||||||
if (!span) return;
|
|
||||||
|
|
||||||
span.classList.remove("status-ok", "status-traveling", "status-hospitalized");
|
|
||||||
|
|
||||||
const s = (member.status || "").toLowerCase();
|
|
||||||
if (s === "okay") span.classList.add("status-ok");
|
|
||||||
else if (s === "traveling" || s === "abroad") span.classList.add("status-traveling");
|
|
||||||
else if (s === "hospitalized" || s === "hospital") span.classList.add("status-hospitalized");
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusClass(status) {
|
function statusClass(status) {
|
||||||
if (!status) return "";
|
if (!status) return "";
|
||||||
status = status.toLowerCase();
|
const s = String(status).toLowerCase();
|
||||||
if (status.includes("okay")) return "status-ok";
|
if (s.includes("okay")) return "status-ok";
|
||||||
if (status.includes("travel") || status.includes("abroad")) return "status-traveling";
|
if (s.includes("travel") || s.includes("abroad")) return "status-traveling";
|
||||||
if (status.includes("hospital")) return "status-hospitalized";
|
if (s.includes("hospital")) return "status-hospitalized";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
//-----------------------------------------------------
|
function applyStatusClass(member) {
|
||||||
// Create a new card DOM element for a 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) {
|
function createMemberCard(member, kind) {
|
||||||
|
// if domElement already exists, reuse it
|
||||||
|
if (member.domElement) return member.domElement;
|
||||||
|
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.classList.add("member-card");
|
card.classList.add("member-card");
|
||||||
card.setAttribute("draggable", "true");
|
card.setAttribute("draggable", "true");
|
||||||
@@ -56,54 +58,57 @@ function createMemberCard(member, kind) {
|
|||||||
const nameDiv = document.createElement("div");
|
const nameDiv = document.createElement("div");
|
||||||
nameDiv.className = "name";
|
nameDiv.className = "name";
|
||||||
nameDiv.textContent = member.name;
|
nameDiv.textContent = member.name;
|
||||||
if (kind === "friendly") nameDiv.style.color = "#8fd38f";
|
nameDiv.style.color = kind === "friendly" ? "#8fd38f" : "#ff6b6b";
|
||||||
if (kind === "enemy") nameDiv.style.color = "#ff6b6b";
|
|
||||||
|
|
||||||
const statsDiv = document.createElement("div");
|
const statsDiv = document.createElement("div");
|
||||||
statsDiv.className = "stats";
|
statsDiv.className = "stats";
|
||||||
statsDiv.innerHTML = `
|
statsDiv.innerHTML = `
|
||||||
Lv: ${member.level} <br>
|
Lv: ${member.level} <br>
|
||||||
Est: ${member.estimate} <br>
|
Est: ${member.estimate} <br>
|
||||||
Status: <span class="status-text ${statusClass(member.status)}">${member.status || "Unknown"}</span>
|
Status: <span class="status-text">${member.status || "Unknown"}</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
card.appendChild(nameDiv);
|
card.appendChild(nameDiv);
|
||||||
card.appendChild(statsDiv);
|
card.appendChild(statsDiv);
|
||||||
|
|
||||||
// save reference
|
// store reference
|
||||||
member.domElement = card;
|
member.domElement = card;
|
||||||
|
|
||||||
// dragging
|
// drag handlers: payload is JSON string
|
||||||
card.addEventListener("dragstart", (e) => {
|
card.addEventListener("dragstart", (e) => {
|
||||||
e.dataTransfer.setData("text/plain", JSON.stringify({
|
e.dataTransfer.setData("text/plain", JSON.stringify({ kind, id: member.id }));
|
||||||
kind,
|
|
||||||
id: member.id
|
|
||||||
}));
|
|
||||||
card.style.opacity = "0.5";
|
card.style.opacity = "0.5";
|
||||||
});
|
});
|
||||||
card.addEventListener("dragend", () => {
|
card.addEventListener("dragend", () => {
|
||||||
card.style.opacity = "1";
|
card.style.opacity = "1";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// apply initial status class
|
||||||
|
applyStatusClass(member);
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
//-----------------------------------------------------
|
|
||||||
// Update an existing card instead of replacing it
|
|
||||||
//-----------------------------------------------------
|
|
||||||
function updateMemberCard(member) {
|
function updateMemberCard(member) {
|
||||||
if (!member.domElement) return;
|
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;
|
||||||
|
|
||||||
const span = member.domElement.querySelector(".status-text");
|
// stats/status
|
||||||
if (span) {
|
const statsEl = member.domElement.querySelector(".stats");
|
||||||
span.textContent = member.status || "Unknown";
|
if (statsEl) {
|
||||||
|
const statusSpan = statsEl.querySelector(".status-text");
|
||||||
|
if (statusSpan) {
|
||||||
|
statusSpan.textContent = member.status || "Unknown";
|
||||||
|
statusSpan.className = "status-text " + statusClass(member.status);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
applyStatusClass(member);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//-----------------------------------------------------
|
// ---------------------------
|
||||||
// Load members WITHOUT recreating card duplicates
|
// Load members from server
|
||||||
//-----------------------------------------------------
|
// ---------------------------
|
||||||
async function loadMembers(kind) {
|
async function loadMembers(kind) {
|
||||||
const url = kind === "friendly" ? "/api/friendly_members" : "/api/enemy_members";
|
const url = kind === "friendly" ? "/api/friendly_members" : "/api/enemy_members";
|
||||||
const container = kind === "friendly" ? friendlyContainer : enemyContainer;
|
const container = kind === "friendly" ? friendlyContainer : enemyContainer;
|
||||||
@@ -112,114 +117,322 @@ async function loadMembers(kind) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(url, { cache: "no-store" });
|
const res = await fetch(url, { cache: "no-store" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error("Error loading members", res.status);
|
console.error("Failed to load members:", res.status);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = await res.json();
|
const list = await res.json();
|
||||||
|
|
||||||
// Keep track of IDs received so we don't remove existing group placements
|
// build set of received ids
|
||||||
const receivedIds = new Set(list.map(m => m.id));
|
const receivedIds = new Set(list.map(m => m.id));
|
||||||
|
|
||||||
// Update existing members or create new ones
|
// update or create
|
||||||
for (const m of list) {
|
for (const m of list) {
|
||||||
let existing = map.get(m.id);
|
let existing = map.get(m.id);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// update info (but keep DOM placement)
|
// update fields (preserve dom & placement)
|
||||||
existing.name = m.name;
|
existing.name = m.name;
|
||||||
existing.level = m.level;
|
existing.level = m.level;
|
||||||
existing.estimate = m.estimate;
|
existing.estimate = m.estimate;
|
||||||
|
// keep existing.status if already set (status refreshes separately)
|
||||||
if (!existing.status) existing.status = "Unknown";
|
if (!existing.status) existing.status = "Unknown";
|
||||||
updateMemberCard(existing);
|
updateMemberCard(existing);
|
||||||
} else {
|
} else {
|
||||||
// NEW member — create card and add to list area
|
|
||||||
const newMember = {
|
const newMember = {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
level: m.level,
|
level: m.level,
|
||||||
estimate: m.estimate,
|
estimate: m.estimate,
|
||||||
status: "Unknown",
|
status: m.status || "Unknown",
|
||||||
domElement: null
|
domElement: null
|
||||||
};
|
};
|
||||||
|
|
||||||
const card = createMemberCard(newMember, kind);
|
|
||||||
map.set(m.id, newMember);
|
map.set(m.id, newMember);
|
||||||
|
// create card but DO NOT automatically append here;
|
||||||
// Only add to container if they are NOT already assigned to a group
|
// placement will be handled by loadAssignmentsFromServer()
|
||||||
if (!isMemberAssigned(newMember.id)) {
|
createMemberCard(newMember, kind);
|
||||||
container.appendChild(card);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove members that no longer exist from map (rare)
|
// remove obsolete members
|
||||||
for (const existingId of map.keys()) {
|
for (const existingId of Array.from(map.keys())) {
|
||||||
if (!receivedIds.has(existingId)) {
|
if (!receivedIds.has(existingId)) {
|
||||||
const member = map.get(existingId);
|
const member = map.get(existingId);
|
||||||
if (member?.domElement?.parentElement) {
|
if (member?.domElement?.parentElement) member.domElement.parentElement.removeChild(member.domElement);
|
||||||
member.domElement.parentElement.removeChild(member.domElement);
|
|
||||||
}
|
|
||||||
map.delete(existingId);
|
map.delete(existingId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupDropZones();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("loadMembers error", err);
|
console.error("loadMembers error", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//-----------------------------------------------------
|
// ---------------------------
|
||||||
// Check if a member is placed in a group zone
|
// Server-side assignments API interactions
|
||||||
//-----------------------------------------------------
|
// ---------------------------
|
||||||
function isMemberAssigned(id) {
|
async function fetchAssignments() {
|
||||||
return document.querySelector(`.member-card[data-id="${id}"]`)?.parentElement?.classList.contains("drop-zone") || false;
|
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) {
|
||||||
// Status refresh
|
|
||||||
//-----------------------------------------------------
|
|
||||||
async function refreshStatus(kind) {
|
|
||||||
const url = kind === "friendly" ? "/api/friendly_status" : "/api/enemy_status";
|
|
||||||
const map = kind === "friendly" ? friendlyMembers : enemyMembers;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { cache: "no-store" });
|
const res = await fetch("/api/assign_member", {
|
||||||
if (!res.ok) return;
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
const statusData = await res.json();
|
body: JSON.stringify({ group_id: groupId, kind, member_id: memberId })
|
||||||
for (const idStr of Object.keys(statusData)) {
|
});
|
||||||
const id = parseInt(idStr);
|
if (!res.ok) {
|
||||||
const member = map.get(id);
|
console.warn("Failed to save assignment:", res.status);
|
||||||
if (!member) continue;
|
|
||||||
|
|
||||||
member.status = statusData[idStr].status;
|
|
||||||
updateMemberCard(member);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("refreshStatus error", err);
|
console.error("saveAssignmentToServer error", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//-----------------------------------------------------
|
async function removeAssignmentFromServer(memberId) {
|
||||||
// Populate endpoints (immediate build + status refresh)
|
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;
|
||||||
|
// If member is not in any group zone, ensure it's in the main list (append if not present)
|
||||||
|
const parent = member.domElement?.parentElement;
|
||||||
|
const isInDropZone = parent && parent.classList && parent.classList.contains("drop-zone");
|
||||||
|
if (!isInDropZone) {
|
||||||
|
if (member.domElement && member.domElement.parentElement !== container) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 (unchanged behavior)
|
||||||
|
// ---------------------------
|
||||||
async function populateFriendly() {
|
async function populateFriendly() {
|
||||||
const id = toInt(document.getElementById("friendly-id").value);
|
const id = toInt(document.getElementById("friendly-id").value);
|
||||||
if (!id) return alert("Enter Friendly Faction ID");
|
if (!id) return alert("Enter Friendly Faction ID");
|
||||||
|
|
||||||
// Send faction_id to FastAPI
|
|
||||||
await fetch("/api/populate_friendly", {
|
await fetch("/api/populate_friendly", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ faction_id: id })
|
body: JSON.stringify({ faction_id: id, interval: 0 })
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load members but do NOT recreate duplicates
|
// reload members and assignments
|
||||||
await loadMembers("friendly");
|
await loadMembers("friendly");
|
||||||
|
await loadMembers("enemy"); // in case population changed cross lists
|
||||||
// Immediately refresh status so cards show correct status
|
await pollAssignments();
|
||||||
await refreshStatus("friendly");
|
await refreshStatus("friendly");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,22 +443,41 @@ async function populateEnemy() {
|
|||||||
await fetch("/api/populate_enemy", {
|
await fetch("/api/populate_enemy", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ faction_id: id })
|
body: JSON.stringify({ faction_id: id, interval: 0 })
|
||||||
});
|
});
|
||||||
|
|
||||||
await loadMembers("enemy");
|
await loadMembers("enemy");
|
||||||
|
await loadMembers("friendly");
|
||||||
|
await pollAssignments();
|
||||||
await refreshStatus("enemy");
|
await refreshStatus("enemy");
|
||||||
}
|
}
|
||||||
|
|
||||||
//-----------------------------------------------------
|
// status refresh (pulls from server status json)
|
||||||
// Status refresh toggling
|
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 friendlyStatusIntervalHandle = null;
|
||||||
let enemyStatusIntervalHandle = null;
|
let enemyStatusIntervalHandle = null;
|
||||||
|
|
||||||
async function toggleFriendlyStatus() {
|
async function toggleFriendlyStatus() {
|
||||||
const btn = document.getElementById("friendly-status-btn");
|
const btn = document.getElementById("friendly-status-btn");
|
||||||
|
|
||||||
if (friendlyStatusIntervalHandle) {
|
if (friendlyStatusIntervalHandle) {
|
||||||
clearInterval(friendlyStatusIntervalHandle);
|
clearInterval(friendlyStatusIntervalHandle);
|
||||||
friendlyStatusIntervalHandle = null;
|
friendlyStatusIntervalHandle = null;
|
||||||
@@ -269,7 +501,6 @@ async function toggleFriendlyStatus() {
|
|||||||
|
|
||||||
async function toggleEnemyStatus() {
|
async function toggleEnemyStatus() {
|
||||||
const btn = document.getElementById("enemy-status-btn");
|
const btn = document.getElementById("enemy-status-btn");
|
||||||
|
|
||||||
if (enemyStatusIntervalHandle) {
|
if (enemyStatusIntervalHandle) {
|
||||||
clearInterval(enemyStatusIntervalHandle);
|
clearInterval(enemyStatusIntervalHandle);
|
||||||
enemyStatusIntervalHandle = null;
|
enemyStatusIntervalHandle = null;
|
||||||
@@ -291,136 +522,46 @@ async function toggleEnemyStatus() {
|
|||||||
btn.textContent = "Stop Refresh";
|
btn.textContent = "Stop Refresh";
|
||||||
}
|
}
|
||||||
|
|
||||||
//-----------------------------------------------------
|
// ---------------------------
|
||||||
// Drag & Drop for all zones
|
// Reset groups (server-side)
|
||||||
//-----------------------------------------------------
|
// ---------------------------
|
||||||
function setupDropZones() {
|
async function resetGroups() {
|
||||||
const zones = document.querySelectorAll(".drop-zone, .member-list");
|
if (!confirm("Reset all group assignments on the server?")) return;
|
||||||
|
await clearAssignmentsOnServer();
|
||||||
zones.forEach(zone => {
|
// reload assignments & UI
|
||||||
if (zone.dataset._drag_listeners_attached) return;
|
await pollAssignments();
|
||||||
|
|
||||||
zone.addEventListener("dragover", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const raw = e.dataTransfer.getData("text/plain");
|
|
||||||
let valid = false;
|
|
||||||
try {
|
|
||||||
const p = JSON.parse(raw);
|
|
||||||
const zoneType = zone.classList.contains("friendly-zone") ? "friendly" :
|
|
||||||
zone.classList.contains("enemy-zone") ? "enemy" :
|
|
||||||
null;
|
|
||||||
|
|
||||||
if (!zoneType || p.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", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
zone.classList.remove("dragover-valid", "dragover-invalid");
|
|
||||||
|
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(e.dataTransfer.getData("text/plain"));
|
|
||||||
} catch { return; }
|
|
||||||
|
|
||||||
const zoneType = zone.classList.contains("friendly-zone") ? "friendly" :
|
|
||||||
zone.classList.contains("enemy-zone") ? "enemy" :
|
|
||||||
null;
|
|
||||||
|
|
||||||
if (zoneType && payload.kind !== zoneType) return;
|
|
||||||
|
|
||||||
const map = payload.kind === "friendly" ? friendlyMembers : enemyMembers;
|
|
||||||
const member = map.get(payload.id);
|
|
||||||
if (!member) return;
|
|
||||||
|
|
||||||
// Move the card
|
|
||||||
const prev = member.domElement.parentElement;
|
|
||||||
if (prev !== zone) prev.removeChild(member.domElement);
|
|
||||||
zone.appendChild(member.domElement);
|
|
||||||
|
|
||||||
saveAssignments();
|
|
||||||
});
|
|
||||||
|
|
||||||
zone.dataset._drag_listeners_attached = "1";
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//-----------------------------------------------------
|
// ---------------------------
|
||||||
// Save and load group assignments
|
// Wire up buttons & init
|
||||||
//-----------------------------------------------------
|
// ---------------------------
|
||||||
function saveAssignments() {
|
|
||||||
const groups = document.querySelectorAll(".group");
|
|
||||||
const data = {};
|
|
||||||
|
|
||||||
groups.forEach(g => {
|
|
||||||
const gid = g.dataset.id;
|
|
||||||
data[gid] = {
|
|
||||||
friendly: [],
|
|
||||||
enemy: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
g.querySelectorAll(".friendly-zone .member-card").forEach(c =>
|
|
||||||
data[gid].friendly.push(parseInt(c.dataset.id))
|
|
||||||
);
|
|
||||||
|
|
||||||
g.querySelectorAll(".enemy-zone .member-card").forEach(c =>
|
|
||||||
data[gid].enemy.push(parseInt(c.dataset.id))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
localStorage.setItem("battleAssignments", JSON.stringify(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadAssignmentsFromStorage() {
|
|
||||||
const data = JSON.parse(localStorage.getItem("battleAssignments") || "{}");
|
|
||||||
|
|
||||||
Object.keys(data).forEach(gid => {
|
|
||||||
const group = document.querySelector(`.group[data-id="${gid}"]`);
|
|
||||||
if (!group) return;
|
|
||||||
|
|
||||||
data[gid].friendly.forEach(id => {
|
|
||||||
const m = friendlyMembers.get(id);
|
|
||||||
if (m?.domElement) group.querySelector(".friendly-zone").appendChild(m.domElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
data[gid].enemy.forEach(id => {
|
|
||||||
const m = enemyMembers.get(id);
|
|
||||||
if (m?.domElement) group.querySelector(".enemy-zone").appendChild(m.domElement);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
//-----------------------------------------------------
|
|
||||||
// Wire up buttons
|
|
||||||
//-----------------------------------------------------
|
|
||||||
function wireUp() {
|
function wireUp() {
|
||||||
document.getElementById("friendly-populate-btn").addEventListener("click", populateFriendly);
|
document.getElementById("friendly-populate-btn").addEventListener("click", populateFriendly);
|
||||||
document.getElementById("enemy-populate-btn").addEventListener("click", populateEnemy);
|
document.getElementById("enemy-populate-btn").addEventListener("click", populateEnemy);
|
||||||
document.getElementById("friendly-status-btn").addEventListener("click", toggleFriendlyStatus);
|
document.getElementById("friendly-status-btn").addEventListener("click", toggleFriendlyStatus);
|
||||||
document.getElementById("enemy-status-btn").addEventListener("click", toggleEnemyStatus);
|
document.getElementById("enemy-status-btn").addEventListener("click", toggleEnemyStatus);
|
||||||
|
const resetBtn = document.getElementById("reset-groups-btn");
|
||||||
|
if (resetBtn) resetBtn.addEventListener("click", resetGroups);
|
||||||
|
|
||||||
document.getElementById("reset-groups-btn").addEventListener("click", () => {
|
setupDropZones();
|
||||||
localStorage.removeItem("battleAssignments");
|
|
||||||
|
|
||||||
friendlyMembers.forEach(m => friendlyContainer.appendChild(m.domElement));
|
|
||||||
enemyMembers.forEach(m => enemyContainer.appendChild(m.domElement));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//-----------------------------------------------------
|
// ---------------------------
|
||||||
// On Load
|
// Initial load
|
||||||
//-----------------------------------------------------
|
// ---------------------------
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
wireUp();
|
wireUp();
|
||||||
|
|
||||||
|
// load members first
|
||||||
await loadMembers("friendly");
|
await loadMembers("friendly");
|
||||||
await loadMembers("enemy");
|
await loadMembers("enemy");
|
||||||
|
|
||||||
loadAssignmentsFromStorage();
|
// 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");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user