Overlord sees all

This commit is contained in:
erik 2025-04-29 22:23:34 +00:00
commit a2089efa02
7 changed files with 724 additions and 0 deletions

0
README.md Normal file
View file

121
db.py Normal file
View file

@ -0,0 +1,121 @@
import sqlite3
from typing import Dict
DB_FILE = "dereth.db"
def init_db() -> None:
"""Create tables if they do not exist (extended with kills_per_hour and onlinetime)."""
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
# History log
c.execute("""
CREATE TABLE IF NOT EXISTS telemetry_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_name TEXT NOT NULL,
char_tag TEXT,
session_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
ew REAL,
ns REAL,
z REAL,
kills INTEGER,
kills_per_hour TEXT,
onlinetime TEXT,
deaths INTEGER,
rares_found INTEGER,
prismatic_taper_count INTEGER,
vt_state TEXT
)
""")
# Live snapshot (upsert)
c.execute("""
CREATE TABLE IF NOT EXISTS live_state (
character_name TEXT PRIMARY KEY,
char_tag TEXT,
session_id TEXT,
timestamp TEXT,
ew REAL,
ns REAL,
z REAL,
kills INTEGER,
kills_per_hour TEXT,
onlinetime TEXT,
deaths INTEGER,
rares_found INTEGER,
prismatic_taper_count INTEGER,
vt_state TEXT
)
""")
conn.commit()
conn.close()
def save_snapshot(data: Dict) -> None:
"""Insert snapshot into history and upsert into live_state (with new fields)."""
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
# Insert full history row
c.execute("""
INSERT INTO telemetry_log (
character_name, char_tag, session_id, timestamp,
ew, ns, z,
kills, kills_per_hour, onlinetime,
deaths, rares_found, prismatic_taper_count, vt_state
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
data["character_name"],
data.get("char_tag", ""),
data["session_id"],
data["timestamp"],
data["ew"], data["ns"], data.get("z", 0.0),
data["kills"],
data.get("kills_per_hour", ""),
data.get("onlinetime", ""),
data.get("deaths", 0),
data.get("rares_found", 0),
data.get("prismatic_taper_count", 0),
data.get("vt_state", "Unknown"),
))
# Upsert into live_state
c.execute("""
INSERT INTO live_state (
character_name, char_tag, session_id, timestamp,
ew, ns, z,
kills, kills_per_hour, onlinetime,
deaths, rares_found, prismatic_taper_count, vt_state
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(character_name) DO UPDATE SET
char_tag = excluded.char_tag,
session_id = excluded.session_id,
timestamp = excluded.timestamp,
ew = excluded.ew,
ns = excluded.ns,
z = excluded.z,
kills = excluded.kills,
kills_per_hour = excluded.kills_per_hour,
onlinetime = excluded.onlinetime,
deaths = excluded.deaths,
rares_found = excluded.rares_found,
prismatic_taper_count = excluded.prismatic_taper_count,
vt_state = excluded.vt_state
""", (
data["character_name"],
data.get("char_tag", ""),
data["session_id"],
data["timestamp"],
data["ew"], data["ns"], data.get("z", 0.0),
data["kills"],
data.get("kills_per_hour", ""),
data.get("onlinetime", ""),
data.get("deaths", 0),
data.get("rares_found", 0),
data.get("prismatic_taper_count", 0),
data.get("vt_state", "Unknown"),
))
conn.commit()
conn.close()

108
main.py Normal file
View file

@ -0,0 +1,108 @@
from datetime import datetime, timedelta, timezone
import json
import sqlite3
from typing import Dict
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import Optional
from db import init_db, save_snapshot, DB_FILE
# ------------------------------------------------------------------
app = FastAPI()
# In-memory store of the last packet per character
live_snapshots: Dict[str, dict] = {}
SHARED_SECRET = "your_shared_secret"
#LOG_FILE = "telemetry_log.jsonl"
# ------------------------------------------------------------------
ACTIVE_WINDOW = timedelta(seconds=30) # player is “online” if seen in last 30 s
class TelemetrySnapshot(BaseModel):
character_name: str
char_tag: str
session_id: str
timestamp: datetime
ew: float # +E / W
ns: float # +N / S
z: float
kills: int
kills_per_hour: Optional[str] = None # now optional
onlinetime: Optional[str] = None # now optional
deaths: int
rares_found: int
prismatic_taper_count: int
vt_state: str
@app.on_event("startup")
def on_startup():
init_db()
# ------------------------ POST ----------------------------------
@app.post("/position")
@app.post("/position/")
async def receive_snapshot(
snapshot: TelemetrySnapshot,
x_plugin_secret: str = Header(None)
):
if x_plugin_secret != SHARED_SECRET:
raise HTTPException(status_code=401, detail="Unauthorized")
# cache for /live
live_snapshots[snapshot.character_name] = snapshot.dict()
# save in sqlite
save_snapshot(snapshot.dict())
# optional log-file append
#with open(LOG_FILE, "a") as f:
# f.write(json.dumps(snapshot.dict(), default=str) + "\n")
print(f"[{datetime.now()}] {snapshot.character_name} @ NS={snapshot.ns:+.2f}, EW={snapshot.ew:+.2f}")
return {"status": "ok"}
# ------------------------ GET -----------------------------------
@app.get("/debug")
def debug():
return {"status": "OK"}
@app.get("/live")
@app.get("/live/")
def get_live_players():
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
rows = conn.execute("SELECT * FROM live_state").fetchall()
conn.close()
# aware cutoff (UTC)
cutoff = datetime.utcnow().replace(tzinfo=timezone.utc) - ACTIVE_WINDOW
players = [
dict(r) for r in rows
if datetime.fromisoformat(
r["timestamp"].replace('Z', '+00:00')
) > cutoff
]
return JSONResponse(content={"players": players})
# -------------------- static frontend ---------------------------
app.mount("/", StaticFiles(directory="static", html=True), name="static")
# list routes for convenience
print("🔍 Registered routes:")
for route in app.routes:
if isinstance(route, APIRoute):
print(f"{route.path} -> {route.methods}")

BIN
static/dereth.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

30
static/index.html Normal file
View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dereth Tracker</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- SIDEBAR -->
<aside id="sidebar">
<!-- Segmented sort buttons -->
<div id="sortButtons" class="sort-buttons"></div>
<h2>Active Players</h2>
<ul id="playerList"></ul>
</aside>
<!-- MAP -->
<div id="mapContainer">
<div id="mapGroup">
<img id="map" src="dereth.png" alt="Dereth map">
<div id="dots"></div>
</div>
<div id="tooltip" class="tooltip"></div>
</div>
<script src="script.js" defer></script>
</body>
</html>

261
static/script.js Normal file
View file

@ -0,0 +1,261 @@
/* ---------- DOM references --------------------------------------- */
const wrap = document.getElementById('mapContainer');
const group = document.getElementById('mapGroup');
const img = document.getElementById('map');
const dots = document.getElementById('dots');
const list = document.getElementById('playerList');
const btnContainer = document.getElementById('sortButtons');
const tooltip = document.getElementById('tooltip');
/* ---------- constants ------------------------------------------- */
const MAX_Z = 10;
const FOCUS_ZOOM = 3; // zoom level when you click a name
const POLL_MS = 2000;
const MAP_BOUNDS = {
west : -102.04,
east : 102.19,
north: 102.16,
south: -102.00
};
/* ---------- sort configuration ---------------------------------- */
const sortOptions = [
{
value: "name",
label: "Name ↑",
comparator: (a, b) => a.character_name.localeCompare(b.character_name)
},
{
value: "kills",
label: "Kills ↓",
comparator: (a, b) => b.kills - a.kills
},
{
value: "rares",
label: "Rares ↓",
comparator: (a, b) => (b.rares_found || 0) - (a.rares_found || 0)
}
];
let currentSort = sortOptions[0];
let currentPlayers = [];
/* ---------- generate segmented buttons -------------------------- */
sortOptions.forEach(opt => {
const btn = document.createElement('div');
btn.className = 'btn';
btn.textContent = opt.label;
btn.dataset.value = opt.value;
if (opt.value === currentSort.value) btn.classList.add('active');
btn.addEventListener('click', () => {
btnContainer.querySelectorAll('.btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentSort = opt;
renderList();
});
btnContainer.appendChild(btn);
});
/* ---------- map & state variables ------------------------------- */
let imgW = 0, imgH = 0;
let scale = 1, offX = 0, offY = 0, minScale = 1;
let dragging = false, sx = 0, sy = 0;
let selected = "";
let pollID = null;
/* ---------- utility functions ----------------------------------- */
const hue = name => {
let h = 0;
for (let c of name) h = c.charCodeAt(0) + ((h << 5) - h);
return `hsl(${Math.abs(h) % 360},72%,50%)`;
};
const loc = (ns, ew) =>
`${Math.abs(ns).toFixed(1)}${ns>=0?"N":"S"} `
+ `${Math.abs(ew).toFixed(1)}${ew>=0?"E":"W"}`;
function worldToPx(ew, ns) {
const x = ((ew - MAP_BOUNDS.west)
/ (MAP_BOUNDS.east - MAP_BOUNDS.west)) * imgW;
const y = ((MAP_BOUNDS.north - ns)
/ (MAP_BOUNDS.north - MAP_BOUNDS.south)) * imgH;
return { x, y };
}
const applyTransform = () =>
group.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`;
function clampPan() {
if (!imgW) return;
const r = wrap.getBoundingClientRect();
const vw = r.width, vh = r.height;
const mw = imgW * scale, mh = imgH * scale;
offX = mw <= vw ? (vw - mw) / 2 : Math.min(0, Math.max(vw - mw, offX));
offY = mh <= vh ? (vh - mh) / 2 : Math.min(0, Math.max(vh - mh, offY));
}
function updateView() {
clampPan();
applyTransform();
}
function fitToWindow() {
const r = wrap.getBoundingClientRect();
scale = Math.min(r.width / imgW, r.height / imgH);
minScale = scale;
updateView();
}
/* ---------- tooltip handlers ------------------------------------ */
function showTooltip(evt, p) {
tooltip.textContent = `${p.character_name} — Kills: ${p.kills} - Kills/h: ${p.kills_per_hour}`;
const r = wrap.getBoundingClientRect();
tooltip.style.left = `${evt.clientX - r.left + 10}px`;
tooltip.style.top = `${evt.clientY - r.top + 10}px`;
tooltip.style.display = 'block';
}
function hideTooltip() {
tooltip.style.display = 'none';
}
/* ---------- polling and initialization -------------------------- */
async function pollLive() {
try {
const { players } = await (await fetch('/live/')).json();
currentPlayers = players;
renderList();
} catch (e) {
console.error('Live fetch failed:', e);
}
}
function startPolling() {
if (pollID !== null) return;
pollLive();
pollID = setInterval(pollLive, POLL_MS);
}
img.onload = () => {
imgW = img.naturalWidth;
imgH = img.naturalHeight;
fitToWindow();
startPolling();
};
/* ---------- rendering sorted list & dots ------------------------ */
function renderList() {
const sorted = [...currentPlayers].sort(currentSort.comparator);
render(sorted);
}
function render(players) {
dots.innerHTML = '';
list.innerHTML = '';
players.forEach(p => {
const { x, y } = worldToPx(p.ew, p.ns);
// dot
const dot = document.createElement('div');
dot.className = 'dot';
dot.style.left = `${x}px`;
dot.style.top = `${y}px`;
dot.style.background = hue(p.character_name);
// custom tooltip
dot.addEventListener('mouseenter', e => showTooltip(e, p));
dot.addEventListener('mousemove', e => showTooltip(e, p));
dot.addEventListener('mouseleave', hideTooltip);
// click to select/zoom
dot.addEventListener('click', () => selectPlayer(p, x, y));
if (p.character_name === selected) dot.classList.add('highlight');
dots.appendChild(dot);
//sidebar
const li = document.createElement('li');
const color = hue(p.character_name);
li.style.borderLeftColor = color;
li.className = 'player-item';
li.innerHTML = `
<span class="player-name">${p.character_name}</span>
<span class="player-loc">${loc(p.ns, p.ew)}</span>
<span class="stat kills">${p.kills}</span>
<span class="stat kph">${p.kills_per_hour}</span>
<span class="stat rares">${p.rares_found}</span>
<span class="stat meta">${p.vt_state}</span>
`;
li.addEventListener('click', () => selectPlayer(p, x, y));
if (p.character_name === selected) li.classList.add('selected');
list.appendChild(li);
});
}
/* ---------- selection centering, focus zoom & blink ------------ */
function selectPlayer(p, x, y) {
selected = p.character_name;
// set focus zoom
scale = Math.min(MAX_Z, Math.max(minScale, FOCUS_ZOOM));
// center on the player
const r = wrap.getBoundingClientRect();
offX = r.width / 2 - x * scale;
offY = r.height / 2 - y * scale;
updateView();
renderList(); // keep sorted + highlight
}
/* ---------- pan & zoom handlers -------------------------------- */
wrap.addEventListener('wheel', e => {
e.preventDefault();
if (!imgW) return;
const r = wrap.getBoundingClientRect();
const mx = (e.clientX - r.left - offX) / scale;
const my = (e.clientY - r.top - offY) / scale;
const factor = e.deltaY > 0 ? 0.9 : 1.1;
let ns = scale * factor;
ns = Math.max(minScale, Math.min(MAX_Z, ns));
offX -= mx * (ns - scale);
offY -= my * (ns - scale);
scale = ns;
updateView();
}, { passive: false });
wrap.addEventListener('mousedown', e => {
dragging = true; sx = e.clientX; sy = e.clientY;
wrap.classList.add('dragging');
});
window.addEventListener('mousemove', e => {
if (!dragging) return;
offX += e.clientX - sx; offY += e.clientY - sy;
sx = e.clientX; sy = e.clientY;
updateView();
});
window.addEventListener('mouseup', () => {
dragging = false; wrap.classList.remove('dragging');
});
wrap.addEventListener('touchstart', e => {
if (e.touches.length !== 1) return;
dragging = true;
sx = e.touches[0].clientX; sy = e.touches[0].clientY;
});
wrap.addEventListener('touchmove', e => {
if (!dragging || e.touches.length !== 1) return;
const t = e.touches[0];
offX += t.clientX - sx; offY += t.clientY - sy;
sx = t.clientX; sy = t.clientY;
updateView();
});
wrap.addEventListener('touchend', () => {
dragging = false;
});

204
static/style.css Normal file
View file

@ -0,0 +1,204 @@
:root {
--sidebar-width: 280px;
--bg-main: #111;
--bg-side: #1a1a1a;
--card: #222;
--card-hov:#333;
--text: #eee;
--accent: #88f;
}
html {
margin: 0;
height: 100%;
width: 100%;
}
body {
margin: 0;
height: 100%;
display: flex;
overflow: hidden;
font-family: "Segoe UI", sans-serif;
background: var(--bg-main);
color: var(--text);
}
/* ---------- sort buttons --------------------------------------- */
.sort-buttons {
display: flex;
gap: 4px;
margin: 12px 16px 8px;
}
.sort-buttons .btn {
flex: 1;
padding: 6px 8px;
background: #222;
color: #eee;
border: 1px solid #555;
border-radius: 4px;
text-align: center;
cursor: pointer;
user-select: none;
font-size: 0.9rem;
}
.sort-buttons .btn.active {
background: var(--accent);
color: #111;
border-color: var(--accent);
}
/* ---------- sidebar --------------------------------------------- */
#sidebar {
width: var(--sidebar-width);
scrollbar-width: none;
background: var(--bg-side);
border-right: 2px solid #333;
box-sizing: border-box;
padding: 18px 16px;
overflow-y: auto;
}
#sidebar h2 {
margin: 8px 0 12px;
font-size: 1.25rem;
color: var(--accent);
}
#playerList {
list-style: none;
margin: 0;
padding: 0;
}
#playerList li {
margin: 4px 0;
padding: 6px 8px;
background: var(--card);
border-left: 4px solid #555;
cursor: pointer;
}
#playerList li:hover {
background: var(--card-hov);
}
#playerList li.selected {
background: #454545;
}
/* ---------- map container --------------------------------------- */
#mapContainer {
flex: 1;
min-width: 0;
min-height: 0;
position: relative;
overflow: hidden;
background: #000;
cursor: grab;
}
#mapContainer.dragging {
cursor: grabbing;
}
#mapGroup {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}
#map {
display: block;
user-select: none;
pointer-events: none;
}
/* ---------- dots ------------------------------------------------ */
#dots {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
.dot {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
border: 1px solid #000;
transform: translate(-50%, -50%);
/* enable events on each dot */
pointer-events: auto;
cursor: pointer;
}
.dot.highlight {
width: 10px;
height: 10px;
animation: blink 0.6s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
/* ---------- tooltip --------------------------------------------- */
.tooltip {
position: absolute;
display: none;
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
pointer-events: none;
white-space: nowrap;
z-index: 1000;
}
/* make each row a flex container */
/* 2-column flex layout for each player row */
/* make each row a flex container */
/* make each row a vertical stack */
/* make each player row into a 3×2 grid */
#playerList li {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto auto;
grid-template-areas:
"name loc"
"kills kph"
"rares meta";
gap: 4px 8px;
margin: 6px 0;
padding: 8px 10px;
background: var(--card);
border-left: 4px solid transparent;
transition: background 0.15s;
font-size: 0.85rem;
}
/* assign each span into its grid cell */
.player-name { grid-area: name; font-weight: 600; color: var(--text); }
.player-loc { grid-area: loc; font-size: 0.75rem; color: #aaa; }
.stat.kills { grid-area: kills; }
.stat.kph { grid-area: kph; }
.stat.rares { grid-area: rares; }
.stat.meta { grid-area: meta; }
/* pill styling */
#playerList li .stat {
background: rgba(255,255,255,0.1);
padding: 4px 8px;
border-radius: 12px;
display: inline-block;
font-size: 0.75rem;
white-space: nowrap;
color: var(--text);
}
/* icons & suffixes */
.stat.kills::before { content: "⚔️ "; }
.stat.kph::after { content: " KPH"; font-size:0.7em; color:#aaa; }
.stat.rares::after { content: " Rares"; font-size:0.7em; color:#aaa; }
.stat.meta {
background: var(--accent);
color: #111;
}
/* hover & selected states */
#playerList li:hover { background: var(--card-hov); }
#playerList li.selected { background: #454545; }