561 lines
20 KiB
Python
561 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""OGame Galaxy Viewer — backend Flask.
|
|
|
|
Servizi:
|
|
- vista galassia/sistema (dati da world.json, aggiornato 1x/giorno)
|
|
- import rapporti di spionaggio via API string (sr-...) con salvataggio JSON
|
|
- consultazione rapporti per pianeta
|
|
|
|
Avvio: python3 app.py (sviluppo)
|
|
Servizio systemd: ogame-galaxy.service
|
|
"""
|
|
import json
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from flask import Flask, jsonify, request, send_from_directory
|
|
|
|
import report_store
|
|
import update_data
|
|
from technames import category_name, item_name
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
STATIC = BASE_DIR / "static"
|
|
LATEST = BASE_DIR / "data" / "latest"
|
|
|
|
app = Flask(__name__, static_folder=str(STATIC), static_url_path="/static")
|
|
|
|
|
|
@app.after_request
|
|
def _no_cache(resp):
|
|
# tool personale: niente cache, sempre file freschi
|
|
if request.path.startswith("/static/") or request.path in ("/", "/rankings"):
|
|
resp.headers["Cache-Control"] = "no-store"
|
|
return resp
|
|
|
|
|
|
# semplice rate-limit di cortesia verso il proxy (10 req/min => max 5/min qui)
|
|
_report_lock = threading.Lock()
|
|
|
|
|
|
def load_config():
|
|
with open(BASE_DIR / "config.json", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def load_world():
|
|
p = LATEST / "world.json"
|
|
if not p.exists():
|
|
return None
|
|
with open(p, encoding="utf-8") as f:
|
|
world = json.load(f)
|
|
return _augment_world_with_reports(world)
|
|
|
|
|
|
def _augment_world_with_reports(world):
|
|
"""Aggiunge i pianeti noti *solo* dai rapporti di spionaggio.
|
|
|
|
L'API pubblica `universe.xml` viene rigenerata di rado: i pianeti dei player
|
|
più recenti possono mancare per giorni, pur essendo visibili nei rapporti.
|
|
Se esiste un rapporto su una coordinata assente, la aggiungiamo (marcata
|
|
`report_only`), associando il giocatore per nome univoco, così galassia,
|
|
finestra giocatore e classifica restano utilizzabili.
|
|
"""
|
|
try:
|
|
idx = report_store._load_index()
|
|
except Exception:
|
|
return world
|
|
reports = idx.get("reports", {})
|
|
planets = world.setdefault("planets", {})
|
|
players = world.get("players", {})
|
|
avatar = world.setdefault("avatar", {})
|
|
|
|
by_name = {}
|
|
for pid, p in players.items():
|
|
n = (p.get("name") or "").strip().lower()
|
|
if n:
|
|
by_name.setdefault(n, []).append(pid)
|
|
|
|
def resolve(rec):
|
|
pids = by_name.get((rec.get("defender_name") or "").strip().lower())
|
|
return pids[0] if pids and len(pids) == 1 else None
|
|
|
|
# 1) pianeti (type != 3): aggiungi quelli mancanti
|
|
for rec in reports.values():
|
|
coords = rec.get("coords")
|
|
if not coords or rec.get("planet_type") == 3 or coords in planets:
|
|
continue
|
|
pid = resolve(rec)
|
|
if not pid:
|
|
continue
|
|
planets[coords] = {
|
|
"id": f"report-{coords}",
|
|
"player": pid,
|
|
"name": rec.get("defender_planet_name") or "(da rapporto)",
|
|
"coords": coords,
|
|
"report_only": True,
|
|
}
|
|
avatar.setdefault(pid, coords)
|
|
|
|
# 2) lune (type == 3): aggancia al pianeta se presente e senza luna nota
|
|
for rec in reports.values():
|
|
if rec.get("planet_type") != 3:
|
|
continue
|
|
base = planets.get(rec.get("coords") or "")
|
|
if base is not None and not base.get("moon"):
|
|
base["moon"] = {
|
|
"id": f"report-{base['coords']}#moon",
|
|
"name": rec.get("defender_planet_name") or "Luna",
|
|
"size": 0,
|
|
}
|
|
return world
|
|
|
|
|
|
def fmt_ts(epoch):
|
|
if not epoch:
|
|
return None
|
|
return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
|
|
|
|
# ---------------------------------------------------------------- pagine
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return send_from_directory(STATIC, "index.html")
|
|
|
|
|
|
# ---------------------------------------------------------------- API dati
|
|
|
|
@app.route("/api/bootstrap")
|
|
def api_bootstrap():
|
|
cfg = load_config()
|
|
world = load_world()
|
|
if not world:
|
|
return jsonify({"ok": False, "error": "Dati non ancora scaricati."}), 503
|
|
rep = report_store.stats(cfg)
|
|
return jsonify({
|
|
"ok": True,
|
|
"server": world["server"],
|
|
"myPlayer": {
|
|
"id": cfg.get("my_player_id"),
|
|
"name": cfg.get("my_player_name"),
|
|
},
|
|
"fetched": {k: fmt_ts(v) for k, v in world.get("fetched", {}).items()},
|
|
"reportStats": rep,
|
|
})
|
|
|
|
|
|
@app.route("/api/galaxy")
|
|
def api_galaxy():
|
|
world = load_world()
|
|
if not world:
|
|
return jsonify({"ok": False, "error": "Dati non disponibili."}), 503
|
|
try:
|
|
g = int(request.args.get("g", 1))
|
|
s = int(request.args.get("s", 1))
|
|
except ValueError:
|
|
return jsonify({"ok": False, "error": "Parametri g/s non validi"}), 400
|
|
sd = world["server"]
|
|
if not (1 <= g <= sd["galaxies"]):
|
|
return jsonify({"ok": False, "error": f"Galassia fuori range (1-{sd['galaxies']})"}), 400
|
|
if not (1 <= s <= sd["systems"]):
|
|
return jsonify({"ok": False, "error": f"Sistema fuori range (1-{sd['systems']})"}), 400
|
|
|
|
players = world["players"]
|
|
planets = world["planets"]
|
|
cells = []
|
|
idx = report_store._load_index()
|
|
reports_idx = idx.get("reports", {})
|
|
for pos in range(1, 16):
|
|
cell = {"pos": pos}
|
|
key = f"{g}:{s}:{pos}"
|
|
planet = planets.get(key)
|
|
if planet:
|
|
pid = planet["player"]
|
|
pl = players.get(pid, {})
|
|
# eventuali rapporti su questo pianeta
|
|
rep_ids = [r for r in idx.get("by_coords", {}).get(key, [])]
|
|
rep_ids += [r for r in idx.get("by_coords", {}).get(key + "#moon", [])]
|
|
reps = []
|
|
for rid in rep_ids:
|
|
rec = reports_idx.get(rid)
|
|
if rec:
|
|
reps.append({
|
|
"sr_id": rid,
|
|
"moon": key + "#moon" in idx.get("by_coords", {}).get(key + "#moon", []),
|
|
"event_time": rec.get("event_time"),
|
|
"activity": rec.get("activity"),
|
|
})
|
|
reps.sort(key=lambda r: r.get("event_time") or "", reverse=True)
|
|
cell["planet"] = {
|
|
"id": planet["id"],
|
|
"name": planet["name"],
|
|
"coords": planet["coords"],
|
|
"report_only": planet.get("report_only", False),
|
|
"mine": str(pid) == str(load_config().get("my_player_id")),
|
|
}
|
|
cell["player"] = {
|
|
"id": pid,
|
|
"name": pl.get("name", ""),
|
|
"status": pl.get("status", ""),
|
|
"alliance_tag": pl.get("alliance_tag", ""),
|
|
}
|
|
if planet.get("moon"):
|
|
cell["moon"] = planet["moon"]
|
|
cell["reports"] = reps
|
|
# posizioni in classifica (generale/armamenti) del giocatore in cella
|
|
if cell.get("player"):
|
|
rk = (world.get("ranks") or {}).get(cell["player"]["id"], {})
|
|
cell["player"]["rank_total"] = rk.get("total")
|
|
cell["player"]["rank_military"] = rk.get("military")
|
|
cells.append(cell)
|
|
return jsonify({"ok": True, "galaxy": g, "system": s,
|
|
"galaxies": sd["galaxies"], "systems": sd["systems"],
|
|
"cells": cells})
|
|
|
|
|
|
@app.route("/api/planet")
|
|
def api_planet():
|
|
"""Dettaglio pianeta: info + tutti i rapporti di spionaggio salvati."""
|
|
world = load_world()
|
|
g = request.args.get("g")
|
|
s = request.args.get("s")
|
|
p = request.args.get("p")
|
|
if not (g and s and p):
|
|
return jsonify({"ok": False, "error": "Parametri g/s/p richiesti"}), 400
|
|
key = f"{g}:{s}:{p}"
|
|
planets = world["planets"]
|
|
planet = planets.get(key)
|
|
if not planet:
|
|
return jsonify({"ok": False, "error": "Pianeta non trovato in questo sistema."}), 404
|
|
player = world["players"].get(planet["player"], {})
|
|
rk = (world.get("ranks") or {}).get(planet["player"], {})
|
|
player = {**player, "rank_total": rk.get("total"), "rank_military": rk.get("military")}
|
|
ally = {}
|
|
if player.get("alliance"):
|
|
ally = world["alliances"].get(player["alliance"], {})
|
|
# altri pianeti dello stesso giocatore
|
|
others = [pl for k, pl in planets.items()
|
|
if pl["player"] == planet["player"] and k != key]
|
|
others.sort(key=lambda x: x["coords"])
|
|
|
|
# rapporti
|
|
reports = [
|
|
_report_view(rec, report_store.get_report(rec["sr_id"]))
|
|
for rec in report_store.list_by_coords(key, scope="planet")
|
|
]
|
|
moon_reports = [
|
|
_report_view(rec, report_store.get_report(rec["sr_id"]))
|
|
for rec in report_store.list_by_coords(key, scope="moon")
|
|
]
|
|
|
|
return jsonify({
|
|
"ok": True,
|
|
"planet": planet,
|
|
"player": {**player, "alliance_name": ally.get("name", "")},
|
|
"other_planets": others,
|
|
"reports": reports,
|
|
"moon_reports": moon_reports,
|
|
})
|
|
|
|
|
|
@app.route("/api/player")
|
|
def api_player():
|
|
"""Dettaglio giocatore: tutti i suoi pianeti + TUTTI i rapporti (pianeti e lune)."""
|
|
world = load_world()
|
|
if not world:
|
|
return jsonify({"ok": False, "error": "Dati non disponibili."}), 503
|
|
pid = request.args.get("id")
|
|
if not pid:
|
|
return jsonify({"ok": False, "error": "Parametro id richiesto"}), 400
|
|
player = world["players"].get(pid)
|
|
if not player:
|
|
return jsonify({"ok": False, "error": "Giocatore non trovato."}), 404
|
|
rk = (world.get("ranks") or {}).get(pid, {})
|
|
player = {**player, "rank_total": rk.get("total"), "rank_military": rk.get("military")}
|
|
ally = world["alliances"].get(player.get("alliance"), {}) if player.get("alliance") else {}
|
|
|
|
planets = [pl for pl in world["planets"].values() if pl["player"] == pid]
|
|
planets.sort(key=lambda x: [int(n) for n in x["coords"].split(":")])
|
|
|
|
# tutti i rapporti del giocatore, su qualunque suo pianeta o luna
|
|
idx = report_store._load_index()
|
|
by = idx.get("by_coords", {})
|
|
reports_meta = idx.get("reports", {})
|
|
seen = set()
|
|
reports = []
|
|
for pl in planets:
|
|
c = pl["coords"]
|
|
for key in (c, f"{c}#moon"):
|
|
for rid in by.get(key, []):
|
|
if rid in seen:
|
|
continue
|
|
rec = reports_meta.get(rid)
|
|
if rec:
|
|
seen.add(rid)
|
|
reports.append(_report_view(rec, report_store.get_report(rid)))
|
|
reports.sort(key=lambda r: r.get("event_timestamp") or 0, reverse=True)
|
|
|
|
return jsonify({
|
|
"ok": True,
|
|
"player": {**player, "alliance_name": ally.get("name", "")},
|
|
"avatar": (world.get("avatar") or {}).get(pid),
|
|
"planets": planets,
|
|
"reports": reports,
|
|
})
|
|
|
|
|
|
def _report_view(rec, full):
|
|
"""Vista sintetica (senza dati grezzi pesanti) per la lista rapporti."""
|
|
rep = (full or {}).get("report", {})
|
|
gen = rep.get("generic", {})
|
|
det = rep.get("details", {})
|
|
res = det.get("resources") or {}
|
|
return {
|
|
"sr_id": rec["sr_id"],
|
|
"saved_at": rec.get("saved_at"),
|
|
"event_time": rec.get("event_time"),
|
|
"event_timestamp": rec.get("event_timestamp"),
|
|
"attacker_name": rec.get("attacker_name"),
|
|
"defender_name": rec.get("defender_name"),
|
|
"defender_planet_name": rec.get("defender_planet_name"),
|
|
"coords": rec.get("coords"),
|
|
"activity": rec.get("activity"),
|
|
"loot_percentage": rec.get("loot_percentage"),
|
|
"spy_fail_chance": gen.get("spy_fail_chance"),
|
|
"total_ship_count": gen.get("total_ship_count"),
|
|
"total_defense_count": gen.get("total_defense_count"),
|
|
"resources": res,
|
|
"failed": {k: gen.get(k) for k in
|
|
("failed_ships", "failed_defense", "failed_buildings", "failed_research")},
|
|
"categories": {
|
|
"buildings": len(det.get("buildings") or []),
|
|
"lfbuildings": len(det.get("lfbuildings") or []),
|
|
"research": len(det.get("research") or []),
|
|
"ships": len(det.get("ships") or []),
|
|
"defense": len(det.get("defense") or []),
|
|
},
|
|
"moon": rec.get("planet_type") == 3,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------- rapporti
|
|
|
|
@app.route("/api/reports", methods=["POST"])
|
|
def api_add_report():
|
|
cfg = load_config()
|
|
body = request.get_json(silent=True) or {}
|
|
token = (body.get("token") or "").strip()
|
|
if not token:
|
|
return jsonify({"ok": False, "error": "Incolla l'API string del rapporto (es. sr-ar-170-...)."}), 400
|
|
with _report_lock:
|
|
try:
|
|
rec, created = report_store.import_report(token, cfg)
|
|
except ValueError as e:
|
|
return jsonify({"ok": False, "error": str(e)}), 400
|
|
except RuntimeError as e:
|
|
return jsonify({"ok": False, "error": str(e)}), 502
|
|
sr_id = rec["meta"]["sr_id"]
|
|
full = report_store.get_report(sr_id)
|
|
view = _report_view(rec["meta"], full)
|
|
return jsonify({"ok": True, "created": created, "report": view,
|
|
"coords": rec["meta"].get("coords"),
|
|
"planet_type": rec["meta"].get("planet_type")})
|
|
|
|
|
|
@app.route("/api/reports/<sr_id>")
|
|
def api_get_report(sr_id):
|
|
full = report_store.get_report(sr_id)
|
|
if not full:
|
|
return jsonify({"ok": False, "error": "Rapporto non trovato."}), 404
|
|
idx = report_store._load_index()
|
|
rec = idx["reports"].get(sr_id)
|
|
return jsonify({"ok": True, "meta": rec, "data": full["report"]})
|
|
|
|
|
|
@app.route("/api/reports/<sr_id>", methods=["DELETE"])
|
|
def api_delete_report(sr_id):
|
|
"""Elimina un rapporto di spionaggio (file + indice)."""
|
|
if not report_store.delete_report(sr_id):
|
|
return jsonify({"ok": False, "error": "Rapporto non trovato."}), 404
|
|
return jsonify({"ok": True, "deleted": sr_id})
|
|
|
|
|
|
@app.route("/api/refresh", methods=["POST"])
|
|
def api_refresh():
|
|
def _run():
|
|
try:
|
|
update_data.main()
|
|
except Exception as e: # noqa
|
|
print("refresh fallito:", e)
|
|
threading.Thread(target=_run, daemon=True).start()
|
|
return jsonify({"ok": True, "message": "Aggiornamento dati avviato in background."})
|
|
|
|
|
|
@app.route("/rankings")
|
|
def rankings_page():
|
|
return send_from_directory(STATIC, "rankings.html")
|
|
|
|
|
|
# mappa stato API -> gruppo di ricerca
|
|
_STATUS_GROUPS = {
|
|
"all": lambda st: True,
|
|
"active": lambda st: st == "",
|
|
"inactive": lambda st: st in ("i", "I"),
|
|
"vacation": lambda st: st == "v",
|
|
"vacation_inactive": lambda st: st in ("vi", "vI"),
|
|
"admin": lambda st: st == "a",
|
|
}
|
|
|
|
|
|
@app.route("/api/rankings")
|
|
def api_rankings():
|
|
"""Classifiche con filtri: type=total|military|fleet, range posizione, stato, testo."""
|
|
world = load_world()
|
|
if not world:
|
|
return jsonify({"ok": False, "error": "Dati non disponibili."}), 503
|
|
rtype = request.args.get("type", "total")
|
|
if rtype not in ("total", "military", "fleet"):
|
|
return jsonify({"ok": False, "error": "type deve essere total, military o fleet"}), 400
|
|
try:
|
|
start = int(request.args.get("start", 1) or 1)
|
|
end = int(request.args.get("end", 0) or 0)
|
|
except ValueError:
|
|
return jsonify({"ok": False, "error": "range non valido"}), 400
|
|
status = request.args.get("status", "all")
|
|
if status not in _STATUS_GROUPS:
|
|
return jsonify({"ok": False, "error": "stato non valido"}), 400
|
|
q = (request.args.get("q") or "").strip().lower()
|
|
|
|
players = world["players"]
|
|
ranks = world.get("ranks", {})
|
|
avatar = world.get("avatar", {})
|
|
pcount = {}
|
|
for pl in world["planets"].values():
|
|
pcount[pl["player"]] = pcount.get(pl["player"], 0) + 1
|
|
|
|
# rapporti di spionaggio per giocatore bersaglio (pianeta + luna) e
|
|
# coordinata del rapporto più recente (per il salto diretto dal badge)
|
|
rcount = {}
|
|
rcoord = {}
|
|
ridx = report_store._load_index()
|
|
reports_meta = ridx.get("reports", {})
|
|
for ckey, rids in ridx.get("by_coords", {}).items():
|
|
planet = world["planets"].get(ckey.split("#")[0])
|
|
if not planet:
|
|
continue
|
|
pid = planet["player"]
|
|
rcount[pid] = rcount.get(pid, 0) + len(rids)
|
|
for rid in rids:
|
|
ts = (reports_meta.get(rid) or {}).get("event_timestamp") or 0
|
|
if pid not in rcoord or ts > rcoord[pid][1]:
|
|
rcoord[pid] = (planet["coords"], ts)
|
|
|
|
# flotte conosciute (tab "Top Flotte"): per ogni coordinata (pianeta o luna)
|
|
# si prende il rapporto più recente, poi si sommano le navi per giocatore.
|
|
latest_by_coord = {}
|
|
for ckey, rids in ridx.get("by_coords", {}).items():
|
|
best = None
|
|
for rid in rids:
|
|
ts = (reports_meta.get(rid) or {}).get("event_timestamp") or 0
|
|
if best is None or ts > best[0]:
|
|
best = (ts, rid)
|
|
if best:
|
|
latest_by_coord[ckey] = best[1]
|
|
pships = {}
|
|
for ckey, rid in latest_by_coord.items():
|
|
planet = world["planets"].get(ckey.split("#")[0])
|
|
if not planet:
|
|
continue
|
|
pid = planet["player"]
|
|
ships = (reports_meta.get(rid) or {}).get("total_ship_count") or 0
|
|
pships[pid] = pships.get(pid, 0) + int(ships)
|
|
|
|
ok_status = _STATUS_GROUPS[status]
|
|
|
|
# --- tab "Top Flotte": solo giocatori con rapporti, ordinati per navi ---
|
|
if rtype == "fleet":
|
|
rows = []
|
|
mine_id = str(load_config().get("my_player_id"))
|
|
for pid, ships in pships.items():
|
|
p = players.get(pid)
|
|
if not p or not ok_status(p.get("status", "")):
|
|
continue
|
|
name = p.get("name", "")
|
|
tag = p.get("alliance_tag", "")
|
|
ally_name = p.get("alliance_name", "")
|
|
if q and not (q in name.lower() or q in tag.lower() or q in ally_name.lower()):
|
|
continue
|
|
rows.append({
|
|
"id": pid, "name": name, "status": p.get("status", ""),
|
|
"alliance_tag": tag, "alliance_name": ally_name,
|
|
"rank": None, "score": int(ships),
|
|
"avatar": avatar.get(pid), "planets": pcount.get(pid, 0),
|
|
"reports": rcount.get(pid, 0),
|
|
"report_coords": rcoord.get(pid, (None,))[0],
|
|
"mine": str(pid) == mine_id,
|
|
})
|
|
rows.sort(key=lambda r: r["score"], reverse=True)
|
|
for i, r in enumerate(rows, 1):
|
|
r["rank"] = i
|
|
if start > 1 or end:
|
|
rows = [r for r in rows
|
|
if r["rank"] >= start and (not end or r["rank"] <= end)]
|
|
return jsonify({"ok": True, "type": "fleet", "count": len(rows), "rows": rows})
|
|
|
|
rows = []
|
|
for pid, p in players.items():
|
|
if not ok_status(p.get("status", "")):
|
|
continue
|
|
name = p.get("name", "")
|
|
tag = p.get("alliance_tag", "")
|
|
ally_name = p.get("alliance_name", "")
|
|
if q and not (q in name.lower() or q in tag.lower() or q in ally_name.lower()):
|
|
continue
|
|
rk = ranks.get(pid, {})
|
|
rank = rk.get(rtype)
|
|
score = rk.get(rtype + "_score")
|
|
# filtro range posizione (solo per chi ha una posizione)
|
|
if (start > 1 or end):
|
|
if rank is None or rank < start or (end and rank > end):
|
|
continue
|
|
rows.append({
|
|
"id": pid,
|
|
"name": name,
|
|
"status": p.get("status", ""),
|
|
"alliance_tag": tag,
|
|
"alliance_name": ally_name,
|
|
"rank": rank,
|
|
"score": int(score) if score is not None else None,
|
|
"avatar": avatar.get(pid),
|
|
"planets": pcount.get(pid, 0),
|
|
"reports": rcount.get(pid, 0),
|
|
"report_coords": rcoord.get(pid, (None,))[0],
|
|
"mine": str(pid) == str(load_config().get("my_player_id")),
|
|
})
|
|
rows.sort(key=lambda r: (r["rank"] is None, r["rank"] or 10 ** 9))
|
|
return jsonify({"ok": True, "type": rtype, "count": len(rows), "rows": rows})
|
|
|
|
|
|
def _ensure_fresh_data():
|
|
"""All'avvio: aggiorna se l'ultimo fetch dei players è più vecchio di refresh_hours."""
|
|
cfg = load_config()
|
|
world = load_world()
|
|
stale = True
|
|
if world:
|
|
ft = world.get("fetched", {}).get("players") or 0
|
|
stale = (time.time() - ft) > cfg.get("refresh_hours", 24) * 3600
|
|
if stale:
|
|
print("Dati datati: aggiornamento all'avvio...")
|
|
try:
|
|
update_data.main()
|
|
except Exception as e:
|
|
print("Aggiornamento iniziale fallito:", e)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_ensure_fresh_data()
|
|
app.run(host="0.0.0.0", port=8899, debug=False)
|