Back to json for server side storage

This commit is contained in:
2025-12-01 20:52:00 -05:00
5 changed files with 476 additions and 105 deletions

View File

@@ -17,4 +17,5 @@ ToDo:
- 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

334
assignments.json Normal file
View 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
}
}
}

View File

@@ -9,8 +9,9 @@ class Member(BaseModel):
level: int
estimate: str
type: str # "friendly" or "enemy"
group: Optional[str] = None # "group1", "group2", ... or None
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):
@@ -30,16 +31,11 @@ class ServerState:
# concurrency lock for async safety
self.lock = asyncio.Lock()
# Assignment helpers (all use the lock when called from async endpoints)
# Assignment helpers
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")
@@ -48,7 +44,7 @@ class ServerState:
if member_id in buckets[kind]:
buckets[kind].remove(member_id)
# add to new group if not present
# add to new group
if member_id not in self.groups[group_key][kind]:
self.groups[group_key][kind].append(member_id)
@@ -58,7 +54,6 @@ class ServerState:
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"]:
@@ -66,33 +61,24 @@ class ServerState:
if member_id in buckets["enemy"]:
buckets["enemy"].remove(member_id)
# clear group attr if member in maps
# 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):
"""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] = {
@@ -101,12 +87,8 @@ class ServerState:
}
return snap
# member add/update helpers
# member 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")
@@ -121,19 +103,18 @@ class ServerState:
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
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):
"""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)
# Single global state
STATE = ServerState(group_count=5)

View File

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

View File

@@ -226,16 +226,24 @@ async function clearAssignmentsOnServer() {
// ---------------------------
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) {
// 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;
@@ -417,39 +425,97 @@ function setupDropZones() {
}
// ---------------------------
// Populate & Status functions (unchanged behavior)
// Populate & Status functions
// ---------------------------
async function populateFriendly() {
const id = toInt(document.getElementById("friendly-id").value);
if (!id) return alert("Enter Friendly Faction ID");
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("/api/populate_friendly", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ faction_id: id, interval: 0 })
});
const data = await res.json();
// reload members and assignments
await loadMembers("friendly");
await loadMembers("enemy"); // in case population changed cross lists
await pollAssignments();
await refreshStatus("friendly");
// 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 = 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();
await loadMembers("enemy");
await loadMembers("friendly");
await pollAssignments();
await refreshStatus("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);
}
}
// status refresh (pulls from server status json)