Pivoted to in memory only storage
This commit is contained in:
@@ -17,4 +17,5 @@ ToDo:
|
|||||||
- 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
|
- 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
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
main.py
142
main.py
@@ -1,4 +1,4 @@
|
|||||||
# main.py (updated)
|
# main.py
|
||||||
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
|
||||||
@@ -7,20 +7,19 @@ from cogs.commands import HitCommands
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, HTTPException
|
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
|
||||||
from services.server_state import STATE, Member
|
from services.torn_api import (
|
||||||
|
populate_friendly,
|
||||||
from services.torn_api import populate_friendly, populate_enemy, start_friendly_status_loop, start_enemy_status_loop
|
populate_enemy,
|
||||||
|
start_friendly_status_loop,
|
||||||
|
start_enemy_status_loop,
|
||||||
|
)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# FastAPI Setup
|
# FastAPI Setup
|
||||||
@@ -56,109 +55,80 @@ class RemoveAssignmentRequest(BaseModel):
|
|||||||
class BotControl(BaseModel):
|
class BotControl(BaseModel):
|
||||||
action: str # "start" or "stop"
|
action: str # "start" or "stop"
|
||||||
|
|
||||||
# ================================
|
# ============================================================
|
||||||
# Helper: load JSON file into STATE
|
# Populate endpoints (memory-only)
|
||||||
# ================================
|
# ============================================================
|
||||||
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")
|
@app.post("/api/populate_friendly")
|
||||||
async def api_populate_friendly(data: FactionRequest):
|
async def api_populate_friendly(data: FactionRequest):
|
||||||
# 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
|
# Return full member info including status
|
||||||
await sync_state_from_file(Path("data/friendly_faction.json"), "friendly")
|
members = [m.model_dump() for m in STATE.friendly.values()]
|
||||||
return {"status": "friendly populated", "id": data.faction_id}
|
return {"status": "friendly populated", "id": data.faction_id, "members": members}
|
||||||
|
|
||||||
@app.post("/api/populate_enemy")
|
@app.post("/api/populate_enemy")
|
||||||
async def api_populate_enemy(data: FactionRequest):
|
async def api_populate_enemy(data: FactionRequest):
|
||||||
await populate_enemy(data.faction_id)
|
await populate_enemy(data.faction_id)
|
||||||
await sync_state_from_file(Path("data/enemy_faction.json"), "enemy")
|
members = [m.model_dump() for m in STATE.enemy.values()]
|
||||||
return {"status": "enemy populated", "id": data.faction_id}
|
return {"status": "enemy populated", "id": data.faction_id, "members": members}
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
|
# ============================================================
|
||||||
# Start status refresh loops
|
# Start status refresh loops
|
||||||
# -----------------------------
|
# ============================================================
|
||||||
@app.post("/api/start_friendly_status")
|
@app.post("/api/start_friendly_status")
|
||||||
async def api_start_friendly_status(data: FactionRequest):
|
async def api_start_friendly_status(data: FactionRequest):
|
||||||
from services.torn_api import start_friendly_status_loop
|
|
||||||
await start_friendly_status_loop(data.faction_id, data.interval)
|
await start_friendly_status_loop(data.faction_id, data.interval)
|
||||||
return {"status": "friendly status loop started", "id": data.faction_id, "interval": data.interval}
|
return {"status": "friendly status loop started", "id": data.faction_id, "interval": data.interval}
|
||||||
|
|
||||||
@app.post("/api/start_enemy_status")
|
@app.post("/api/start_enemy_status")
|
||||||
async def api_start_enemy_status(data: FactionRequest):
|
async def api_start_enemy_status(data: FactionRequest):
|
||||||
from services.torn_api import start_enemy_status_loop
|
|
||||||
await start_enemy_status_loop(data.faction_id, data.interval)
|
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 (unchanged)
|
# Member endpoints
|
||||||
# =============================
|
# ============================================================
|
||||||
@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 a list of all friendly members with all their info,
|
||||||
return [m.model_dump() for m in STATE.friendly.values()]
|
including current status.
|
||||||
# fallback to file
|
"""
|
||||||
path = Path("data/friendly_faction.json")
|
return [member.model_dump() for member in STATE.friendly.values()]
|
||||||
return _load_json_list(path)
|
|
||||||
|
|
||||||
@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()]
|
Return a list of all enemy members with all their info,
|
||||||
path = Path("data/enemy_faction.json")
|
including current status.
|
||||||
return _load_json_list(path)
|
"""
|
||||||
|
return [member.model_dump() for member in STATE.enemy.values()]
|
||||||
|
|
||||||
# =============================
|
# ============================================================
|
||||||
# Status JSON endpoints (unchanged)
|
# Status endpoints
|
||||||
# =============================
|
# ============================================================
|
||||||
@app.get("/api/friendly_status")
|
@app.get("/api/friendly_status")
|
||||||
async def api_friendly_status():
|
async def api_friendly_status():
|
||||||
path = Path("data/friendly_status.json")
|
"""
|
||||||
if not path.exists():
|
Return a dictionary of friendly member IDs to their current status.
|
||||||
return {}
|
Example: { 12345: "Online", 67890: "Offline", ... }
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
"""
|
||||||
return json.load(f)
|
return {mid: member.status for mid, member in STATE.friendly.items()}
|
||||||
|
|
||||||
|
|
||||||
@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")
|
"""
|
||||||
if not path.exists():
|
Return a dictionary of enemy member IDs to their current status.
|
||||||
return {}
|
"""
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
return {mid: member.status for mid, member in STATE.enemy.items()}
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
# =============================
|
# ============================================================
|
||||||
# Assignment endpoints (server-side)
|
# Assignment endpoints
|
||||||
# =============================
|
# ============================================================
|
||||||
@app.get("/api/assignments")
|
@app.get("/api/assignments")
|
||||||
async def api_get_assignments():
|
async def api_get_assignments():
|
||||||
"""
|
|
||||||
Return assignments snapshot:
|
|
||||||
{ "1": { "friendly": [...], "enemy": [...] }, "2": {...}, ... }
|
|
||||||
"""
|
|
||||||
snap = await STATE.get_assignments_snapshot()
|
snap = await STATE.get_assignments_snapshot()
|
||||||
return snap
|
return snap
|
||||||
|
|
||||||
@@ -172,17 +142,14 @@ async def api_assign_member(req: AssignMemberRequest):
|
|||||||
if group_id not in STATE.groups:
|
if group_id not in STATE.groups:
|
||||||
raise HTTPException(status_code=400, detail="invalid group_id")
|
raise HTTPException(status_code=400, detail="invalid group_id")
|
||||||
|
|
||||||
# Validate member exists in STATE (if not present, attempt to load from file)
|
# Validate kind
|
||||||
if kind not in ("friendly", "enemy"):
|
if kind not in ("friendly", "enemy"):
|
||||||
raise HTTPException(status_code=400, detail="invalid kind")
|
raise HTTPException(status_code=400, detail="invalid kind")
|
||||||
|
|
||||||
|
# Validate member exists in STATE
|
||||||
coll = STATE.friendly if kind == "friendly" else STATE.enemy
|
coll = STATE.friendly if kind == "friendly" else STATE.enemy
|
||||||
if member_id not in coll:
|
if member_id not in coll:
|
||||||
# Try to load members from file into STATE and re-check
|
raise HTTPException(status_code=404, detail="member not found")
|
||||||
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)
|
await STATE.assign_member(member_id, kind, group_id)
|
||||||
return {"status": "ok", "group": group_id, "kind": kind, "member_id": member_id}
|
return {"status": "ok", "group": group_id, "kind": kind, "member_id": member_id}
|
||||||
@@ -198,9 +165,9 @@ async def api_clear_assignments():
|
|||||||
await STATE.clear_all_assignments()
|
await STATE.clear_all_assignments()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# =============================
|
# ============================================================
|
||||||
# Bot control endpoint
|
# Bot control endpoint
|
||||||
# =============================
|
# ============================================================
|
||||||
@app.post("/api/bot_control")
|
@app.post("/api/bot_control")
|
||||||
async def api_bot_control(req: BotControl):
|
async def api_bot_control(req: BotControl):
|
||||||
if req.action not in ("start", "stop"):
|
if req.action not in ("start", "stop"):
|
||||||
@@ -208,7 +175,6 @@ async def api_bot_control(req: BotControl):
|
|||||||
STATE.bot_running = (req.action == "start")
|
STATE.bot_running = (req.action == "start")
|
||||||
return {"status": "ok", "bot_running": STATE.bot_running}
|
return {"status": "ok", "bot_running": STATE.bot_running}
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Discord Bot Setup
|
# Discord Bot Setup
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -9,8 +9,9 @@ class Member(BaseModel):
|
|||||||
level: int
|
level: int
|
||||||
estimate: str
|
estimate: str
|
||||||
type: str # "friendly" or "enemy"
|
type: str # "friendly" or "enemy"
|
||||||
group: Optional[str] = None # "group1", "group2", ... or None
|
group: Optional[str] = None # "1", "2", ...
|
||||||
hits: int = 0
|
hits: int = 0
|
||||||
|
status: str = "Unknown" # Added status field for Torn API status
|
||||||
|
|
||||||
class ServerState:
|
class ServerState:
|
||||||
def __init__(self, group_count: int = 5):
|
def __init__(self, group_count: int = 5):
|
||||||
@@ -30,16 +31,11 @@ class ServerState:
|
|||||||
# concurrency lock for async safety
|
# concurrency lock for async safety
|
||||||
self.lock = asyncio.Lock()
|
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):
|
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:
|
async with self.lock:
|
||||||
if kind not in ("friendly", "enemy"):
|
if kind not in ("friendly", "enemy"):
|
||||||
raise ValueError("invalid kind")
|
raise ValueError("invalid kind")
|
||||||
|
|
||||||
if group_key not in self.groups:
|
if group_key not in self.groups:
|
||||||
raise ValueError("invalid group_key")
|
raise ValueError("invalid group_key")
|
||||||
|
|
||||||
@@ -48,7 +44,7 @@ class ServerState:
|
|||||||
if member_id in buckets[kind]:
|
if member_id in buckets[kind]:
|
||||||
buckets[kind].remove(member_id)
|
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]:
|
if member_id not in self.groups[group_key][kind]:
|
||||||
self.groups[group_key][kind].append(member_id)
|
self.groups[group_key][kind].append(member_id)
|
||||||
|
|
||||||
@@ -58,7 +54,6 @@ class ServerState:
|
|||||||
coll[member_id].group = group_key
|
coll[member_id].group = group_key
|
||||||
|
|
||||||
async def remove_member_assignment(self, member_id: int):
|
async def remove_member_assignment(self, member_id: int):
|
||||||
"""Remove member from any group (both friendly and enemy)."""
|
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
for gk, buckets in self.groups.items():
|
for gk, buckets in self.groups.items():
|
||||||
if member_id in buckets["friendly"]:
|
if member_id in buckets["friendly"]:
|
||||||
@@ -66,33 +61,24 @@ class ServerState:
|
|||||||
if member_id in buckets["enemy"]:
|
if member_id in buckets["enemy"]:
|
||||||
buckets["enemy"].remove(member_id)
|
buckets["enemy"].remove(member_id)
|
||||||
|
|
||||||
# clear group attr if member in maps
|
# clear group attribute
|
||||||
if member_id in self.friendly:
|
if member_id in self.friendly:
|
||||||
self.friendly[member_id].group = None
|
self.friendly[member_id].group = None
|
||||||
if member_id in self.enemy:
|
if member_id in self.enemy:
|
||||||
self.enemy[member_id].group = None
|
self.enemy[member_id].group = None
|
||||||
|
|
||||||
async def clear_all_assignments(self):
|
async def clear_all_assignments(self):
|
||||||
"""Clear all group lists and member.group fields and save to disk."""
|
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
# clear in-memory groups
|
|
||||||
for gk in self.groups:
|
for gk in self.groups:
|
||||||
self.groups[gk]["friendly"].clear()
|
self.groups[gk]["friendly"].clear()
|
||||||
self.groups[gk]["enemy"].clear()
|
self.groups[gk]["enemy"].clear()
|
||||||
|
|
||||||
# clear group for known members
|
|
||||||
for m in self.friendly.values():
|
for m in self.friendly.values():
|
||||||
m.group = None
|
m.group = None
|
||||||
for m in self.enemy.values():
|
for m in self.enemy.values():
|
||||||
m.group = None
|
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]]]:
|
async def get_assignments_snapshot(self) -> Dict[str, Dict[str, List[int]]]:
|
||||||
"""Return a copy snapshot of the groups dictionary."""
|
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
# shallow copy of structure
|
|
||||||
snap = {}
|
snap = {}
|
||||||
for gk, buckets in self.groups.items():
|
for gk, buckets in self.groups.items():
|
||||||
snap[gk] = {
|
snap[gk] = {
|
||||||
@@ -101,12 +87,8 @@ class ServerState:
|
|||||||
}
|
}
|
||||||
return snap
|
return snap
|
||||||
|
|
||||||
# member add/update helpers
|
# member helpers
|
||||||
async def upsert_member(self, member_data: dict, kind: str):
|
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:
|
async with self.lock:
|
||||||
if kind not in ("friendly", "enemy"):
|
if kind not in ("friendly", "enemy"):
|
||||||
raise ValueError("invalid kind")
|
raise ValueError("invalid kind")
|
||||||
@@ -121,19 +103,18 @@ class ServerState:
|
|||||||
estimate=str(member_data.get("estimate", "?")),
|
estimate=str(member_data.get("estimate", "?")),
|
||||||
type=kind,
|
type=kind,
|
||||||
group=existing_group,
|
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
|
coll[mid] = member
|
||||||
|
|
||||||
async def remove_missing_members(self, received_ids: List[int], kind: str):
|
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:
|
async with self.lock:
|
||||||
coll = self.friendly if kind == "friendly" else self.enemy
|
coll = self.friendly if kind == "friendly" else self.enemy
|
||||||
to_remove = [mid for mid in coll.keys() if mid not in set(received_ids)]
|
to_remove = [mid for mid in coll.keys() if mid not in set(received_ids)]
|
||||||
for mid in to_remove:
|
for mid in to_remove:
|
||||||
# remove from groups too
|
|
||||||
await self.remove_member_assignment(mid)
|
await self.remove_member_assignment(mid)
|
||||||
del coll[mid]
|
del coll[mid]
|
||||||
|
|
||||||
# single global state (5 groups)
|
# Single global state
|
||||||
STATE = ServerState(group_count=5)
|
STATE = ServerState(group_count=5)
|
||||||
|
|||||||
@@ -1,30 +1,28 @@
|
|||||||
# services/torn_api.py
|
# services/torn_api.py
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import json
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
|
||||||
from config import TORN_API_KEY
|
from config import TORN_API_KEY
|
||||||
from .ffscouter import fetch_batch_stats
|
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
|
# Tasks
|
||||||
|
# -----------------------------
|
||||||
friendly_status_task = None
|
friendly_status_task = None
|
||||||
enemy_status_task = None
|
enemy_status_task = None
|
||||||
|
|
||||||
# Locks
|
# Locks for safe async updates
|
||||||
friendly_lock = asyncio.Lock()
|
friendly_lock = asyncio.Lock()
|
||||||
enemy_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):
|
async def populate_faction(faction_id: int, kind: str):
|
||||||
"""Fetch members + FFScouter estimates once and save static info + initial status."""
|
"""
|
||||||
|
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}"
|
url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}"
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
@@ -42,42 +40,37 @@ async def populate_faction(faction_id: int, members_file: Path, status_file: Pat
|
|||||||
if not member_ids:
|
if not member_ids:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Fetch FFScouter data once
|
# Fetch FFScouter estimates
|
||||||
ff_data = await fetch_batch_stats(member_ids)
|
ff_data = await fetch_batch_stats(member_ids)
|
||||||
|
|
||||||
# Build static member list
|
received_ids = []
|
||||||
members = []
|
async with (friendly_lock if kind == "friendly" else enemy_lock):
|
||||||
status_data = {}
|
for m in members_list:
|
||||||
for m in members_list:
|
pid = m["id"]
|
||||||
pid = m["id"]
|
est = ff_data.get(str(pid), {}).get("bs_estimate_human", "?")
|
||||||
est = ff_data.get(str(pid), {}).get("bs_estimate_human", "?")
|
status = m.get("status", {}).get("state", "Unknown")
|
||||||
member = {
|
member_data = {
|
||||||
"id": pid,
|
"id": pid,
|
||||||
"name": m.get("name", "Unknown"),
|
"name": m.get("name", "Unknown"),
|
||||||
"level": m.get("level", 0),
|
"level": m.get("level", 0),
|
||||||
"estimate": est,
|
"estimate": est,
|
||||||
}
|
"status": status
|
||||||
members.append(member)
|
}
|
||||||
# initial status
|
await STATE.upsert_member(member_data, kind)
|
||||||
status_data[pid] = {"status": m.get("status", {}).get("state", "Unknown")}
|
received_ids.append(pid)
|
||||||
|
|
||||||
# Save members
|
# Remove missing members from STATE
|
||||||
members_file.parent.mkdir(exist_ok=True, parents=True)
|
await STATE.remove_missing_members(received_ids, kind)
|
||||||
with open(members_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(members, f, indent=2)
|
|
||||||
|
|
||||||
# Save initial status
|
|
||||||
with open(status_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(status_data, f, indent=2)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Status refresh loop
|
# Status refresh loop
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
async def refresh_status_loop(faction_id: int, status_file: Path, lock: asyncio.Lock, interval: int):
|
async def refresh_status_loop(faction_id: int, kind: str, lock: asyncio.Lock, interval: int):
|
||||||
"""Refresh only status from Torn API periodically."""
|
"""
|
||||||
|
Periodically refresh member statuses in STATE.
|
||||||
|
"""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}"
|
url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}"
|
||||||
@@ -90,43 +83,39 @@ async def refresh_status_loop(faction_id: int, status_file: Path, lock: asyncio.
|
|||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
|
|
||||||
members_list = data.get("members", [])
|
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:
|
async with lock:
|
||||||
with open(status_file, "w", encoding="utf-8") as f:
|
coll = STATE.friendly if kind == "friendly" else STATE.enemy
|
||||||
json.dump(status_data, f, indent=2)
|
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:
|
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)
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Helper functions for endpoints
|
# Public API helpers
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
async def populate_friendly(faction_id: int):
|
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):
|
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):
|
async def start_friendly_status_loop(faction_id: int, interval: int):
|
||||||
global friendly_status_task
|
global friendly_status_task
|
||||||
if friendly_status_task and not friendly_status_task.done():
|
if friendly_status_task and not friendly_status_task.done():
|
||||||
friendly_status_task.cancel()
|
friendly_status_task.cancel()
|
||||||
friendly_status_task = asyncio.create_task(
|
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):
|
async def start_enemy_status_loop(faction_id: int, interval: int):
|
||||||
global enemy_status_task
|
global enemy_status_task
|
||||||
if enemy_status_task and not enemy_status_task.done():
|
if enemy_status_task and not enemy_status_task.done():
|
||||||
enemy_status_task.cancel()
|
enemy_status_task.cancel()
|
||||||
enemy_status_task = asyncio.create_task(
|
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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -425,39 +425,97 @@ function setupDropZones() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// Populate & Status functions (unchanged behavior)
|
// Populate & Status functions
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
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");
|
||||||
|
|
||||||
await fetch("/api/populate_friendly", {
|
try {
|
||||||
method: "POST",
|
const res = await fetch("/api/populate_friendly", {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "POST",
|
||||||
body: JSON.stringify({ faction_id: id, interval: 0 })
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
body: JSON.stringify({ faction_id: id, interval: 0 })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
// reload members and assignments
|
// Update in-memory map & DOM
|
||||||
await loadMembers("friendly");
|
if (data.members) {
|
||||||
await loadMembers("enemy"); // in case population changed cross lists
|
for (const m of data.members) {
|
||||||
await pollAssignments();
|
let existing = friendlyMembers.get(m.id);
|
||||||
await refreshStatus("friendly");
|
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() {
|
async function populateEnemy() {
|
||||||
const id = toInt(document.getElementById("enemy-id").value);
|
const id = toInt(document.getElementById("enemy-id").value);
|
||||||
if (!id) return alert("Enter Enemy Faction ID");
|
if (!id) return alert("Enter Enemy Faction ID");
|
||||||
|
|
||||||
await fetch("/api/populate_enemy", {
|
try {
|
||||||
method: "POST",
|
const res = await fetch("/api/populate_enemy", {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "POST",
|
||||||
body: JSON.stringify({ faction_id: id, interval: 0 })
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
body: JSON.stringify({ faction_id: id, interval: 0 })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
await loadMembers("enemy");
|
if (data.members) {
|
||||||
await loadMembers("friendly");
|
for (const m of data.members) {
|
||||||
await pollAssignments();
|
let existing = enemyMembers.get(m.id);
|
||||||
await refreshStatus("enemy");
|
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)
|
// status refresh (pulls from server status json)
|
||||||
|
|||||||
Reference in New Issue
Block a user