Compare commits

...
Sign in to create a new pull request.

6 commits

Author SHA1 Message Date
erik
d396942deb Chat window is now movable 2025-05-18 11:14:43 +00:00
erik
0313c2a2ae Added docker file 2025-05-15 07:43:17 +00:00
erik
b94f064118 Alex ville ha färger på metastate för att han är en fisk 2025-05-14 09:21:19 +00:00
erik
337eff56aa added favicon 2025-05-09 23:31:01 +00:00
erik
73ae756e5c ws version with nice DB select 2025-05-09 22:35:41 +00:00
erik
a121d57a13 new Websockets from client only 2025-05-04 14:45:27 +00:00
11 changed files with 734 additions and 108 deletions

28
Dockerfile Normal file
View file

@ -0,0 +1,28 @@
# Dockerfile for Dereth Tracker application
FROM python:3.12-slim
# Set working directory
WORKDIR /app
# Upgrade pip and install Python dependencies
RUN python -m pip install --upgrade pip && \
pip install --no-cache-dir fastapi uvicorn pydantic pandas matplotlib websockets
# Copy application code
COPY static/ /app/static/
COPY main.py /app/main.py
COPY db.py /app/db.py
COPY Dockerfile /Dockerfile
# Expose the application port
EXPOSE 8765
# Default environment variables (override as needed)
ENV DB_MAX_SIZE_MB=2048 \
DB_RETENTION_DAYS=7 \
DB_MAX_SQL_LENGTH=1000000000 \
DB_MAX_SQL_VARIABLES=32766 \
DB_WAL_AUTOCHECKPOINT_PAGES=1000 \
SHARED_SECRET=your_shared_secret
# Run the FastAPI application with Uvicorn
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765", "--reload", "--workers", "1"]

48
FIXES.md Normal file
View file

@ -0,0 +1,48 @@
# Planned Fixes and Enhancements
_This document captures the next set of improvements and fixes for Dereth Tracker._
## 1. Chat Window Styling and Format
- **Terminal-style chat interface**
- Redesign the chat window to mimic Asherons Call in-game chat: monospaced font, dark semi-transparent background, and text entry at the bottom.
- Implement timestamped message prefixes (e.g., `[12:34] character: message`).
- Support command- and system-level styling (e.g., whispers, party chat) with distinct color cues.
## 2. Incoming Message Parsing
- **Strip protocol overhead**
- Remove JSON envelope artifacts (e.g., remove quotes, braces) so only raw message text appears.
- Validate and sanitize incoming payloads (e.g., escape HTML, truncate length).
- Optionally support rich-text / emotes by parsing simple markup (e.g., `*bold*`, `/me action`).
## 3. Message Color Scheme
- **Per-character consistent colors**
- Map each character name to a unique, but legible, pastel or muted color.
- Ensure sufficient contrast with the chat background (WCAG AA compliance).
- Provide user override settings for theme (light/dark) and custom palettes.
## 4. Command Prompt Integration
- **Client-side command entry**
- Allow slash-commands in chat input (e.g., `/kick PlayerName`, `/whisper PlayerName Hello`).
- Validate commands before sending to `/ws/live` and route to the correct plugin WebSocket.
- Show feedback on command success/failure in the chat window.
## 5. Security Hardening
- **Authentication & Authorization**
- Enforce TLS (HTTPS/WSS) for all HTTP and WebSocket connections.
- Protect `/ws/position` with rotating shared secrets or token-based auth (e.g., JWT).
- Rate-limit incoming telemetry and chat messages to prevent flooding.
- Sanitize all inputs to guard against injection (SQL, XSS) and implement strict CSP headers.
## 6. Performance and Scalability
- **Throttling and Load Handling**
- Batch updates during high-frequency telemetry bursts to reduce WebSocket churn.
- Cache recent `/live` and `/trails` responses in-memory to relieve SQLite under load.
- Plan for horizontal scaling: stateless FastAPI behind a load balancer with shared database or in-memory pub/sub.
## 7. Testing and Quality Assurance
- **Automated Tests**
- Unit tests for `db.save_snapshot`, HTTP endpoints, and WebSocket handlers.
- E2E tests for the frontend UI (using Puppeteer or Playwright) to verify chat and map functionality.
- Security regression tests for input sanitization and auth enforcement.
_Refer to this list when planning next development sprints. Each item should be broken down into individual tickets or pull requests._

38
LESSONSLEARNED.md Normal file
View file

@ -0,0 +1,38 @@
# Lessons Learned
_This document captures the key takeaways and implementation details from today's troubleshooting session._
## 1. API Routing & Proxy Configuration
- **API_BASE constant**: The frontend (`static/script.js`) uses a base path `API_BASE` (default `/api`) to prefix all HTTP and WebSocket calls. Always update this to match your proxy mount point.
- **Nginx WebSocket forwarding**: To proxy WebSockets, you must forward the `Upgrade` and `Connection` headers:
```nginx
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```
Without these, the WS handshake downgrades to a normal HTTP GET, resulting in 404s.
## 2. Debugging WebSocket Traffic
- Logged all incoming WS frames in `main.py`:
- `[WS-PLUGIN RX] <client>: <raw>` for messages on `/ws/position`
- `[WS-LIVE RX] <client>: <parsed-json>` for messages on `/ws/live`
- These prints surface registration, telemetry, chat, and command packets, aiding root-cause analysis.
## 3. Data Serialization Fix
- Python `datetime` objects are not JSON-serializable by default. We wrapped outbound payloads via FastAPIs `jsonable_encoder` in `_broadcast_to_browser_clients` so that:
```python
data = jsonable_encoder(snapshot)
await ws.send_json(data)
```
This ensures ISO8601 strings for timestamps and eliminates `TypeError: Object of type datetime is not JSON serializable`.
## 4. Frontend Adjustments
- **Chat input positioning**: Moved the `.chat-form` to `position: absolute; bottom: 0;` so the input always sticks to the bottom of its window.
- **Text color**: Forced the input text and placeholder to white (`.chat-input, .chat-input::placeholder { color: #fff; }`) and forcibly set all incoming messages to white via `.chat-messages div { color: #fff !important; }`.
- **Padding for messages**: Added `padding-bottom` to `.chat-messages` to avoid new messages being hidden behind the fixed input bar.
## 5. General Best Practices
- Clear browser cache after updating static assets to avoid stale JS/CSS.
- Keep patches targeted: fix the source of issues (e.g., JSON encoding or missing headers) rather than applying superficial workarounds.
- Use consistent CSS variables for theming (e.g., `--text`, `--bg-main`).
By consolidating these lessons, we can onboard faster next time and avoid repeating these pitfalls.

View file

@ -1,6 +1,6 @@
# Dereth Tracker
Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a SQLite database, and provides both a live map interface and an analytics dashboard.
Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a SQLite database, and provides a live map interface along with a sample data generator for testing.
## Table of Contents
- [Overview](#overview)
@ -12,7 +12,6 @@ Dereth Tracker is a real-time telemetry service for the world of Dereth. It coll
- [API Reference](#api-reference)
- [Frontend](#frontend)
- [Database Schema](#database-schema)
- [Sample Payload](#sample-payload)
- [Contributing](#contributing)
## Overview
@ -21,16 +20,16 @@ This project provides:
- A FastAPI backend with endpoints for receiving and querying telemetry data.
- SQLite-based storage for snapshots and live state.
- A live, interactive map using static HTML, CSS, and JavaScript.
- An analytics dashboard for visualizing kills and session metrics.
- A sample data generator script (`generate_data.py`) for simulating telemetry snapshots.
## Features
- **POST /position**: Submit a telemetry snapshot (protected by a shared secret).
- **WebSocket /ws/position**: Stream telemetry snapshots (protected by a shared secret).
- **GET /live**: Fetch active players seen in the last 30 seconds.
- **GET /history**: Retrieve historical telemetry data with optional time filtering.
- **GET /debug**: Health check endpoint.
- **Live Map**: Interactive map interface with panning, zooming, and sorting.
- **Analytics Dashboard**: Interactive charts for kills over time and kills per hour using D3.js.
- **Sample Data Generator**: `generate_data.py` sends telemetry snapshots over WebSocket for testing.
## Requirements
@ -45,6 +44,7 @@ Python packages:
- pydantic
- pandas
- matplotlib
- websockets # required for sample data generator
## Installation
@ -60,13 +60,14 @@ Python packages:
```
3. Install dependencies:
```bash
pip install fastapi uvicorn pydantic pandas matplotlib
pip install fastapi uvicorn pydantic pandas matplotlib websockets
```
## Configuration
- Update the `SHARED_SECRET` in `main.py` to match your plugin (default: `"your_shared_secret"`).
- The SQLite database file `dereth.db` is created in the project root. To change the path, edit `DB_FILE` in `db.py`.
- To limit the maximum database size, set the environment variable `DB_MAX_SIZE_MB` (default: 2048 MB).
## Usage
@ -76,17 +77,66 @@ Start the server using Uvicorn:
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
- Live Map: `http://localhost:8000/`
- Analytics Dashboard: `http://localhost:8000/graphs.html`
## NGINX Proxy Configuration
If you cannot reassign the existing `/live` and `/trails` routes, you can namespace this service under `/api` (or any other prefix) and configure NGINX accordingly. Be sure to forward WebSocket upgrade headers so that `/ws/live` and `/ws/position` continue to work. Example:
```nginx
location /api/ {
proxy_pass http://127.0.0.1:8765/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
}
```
Then the browser client (static/script.js) will fetch `/api/live/` and `/api/trails/` to reach this new server.
- Live Map: `http://localhost:8000/` (or `http://<your-domain>/api/` if behind a prefix)
### Frontend Configuration
- In `static/script.js`, the constant `API_BASE` controls where live/trails data and WebSocket `/ws/live` are fetched. By default:
```js
const API_BASE = '/api';
```
Update `API_BASE` if you mount the service under a different path or serve it at root.
### Debugging WebSockets
- Server logs now print every incoming WebSocket frame in `main.py`:
- `[WS-PLUGIN RX] <client>: <raw-payload>` for plugin messages on `/ws/position`
- `[WS-LIVE RX] <client>: <parsed-json>` for browser messages on `/ws/live`
- Use these logs to verify messages and troubleshoot handshake failures.
### Styling Adjustments
- Chat input bar is fixed at the bottom of the chat window (`.chat-form { position:absolute; bottom:0; }`).
- Input text and placeholder are white for readability (`.chat-input, .chat-input::placeholder { color:#fff; }`).
- Incoming chat messages forced white via `.chat-messages div { color:#fff !important; }`.
## API Reference
### POST /position
Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: <shared_secret>`.
### WebSocket /ws/position
Stream telemetry snapshots over a WebSocket connection. Provide your shared secret either as a query parameter or WebSocket header:
```
ws://<host>:<port>/ws/position?secret=<shared_secret>
```
or
```
X-Plugin-Secret: <shared_secret>
```
After connecting, send JSON messages matching the `TelemetrySnapshot` schema. For example:
**Request Body Example:**
```json
{
"type": "telemetry",
"character_name": "Dunking Rares",
"char_tag": "moss",
"session_id": "dunk-20250422-xyz",
@ -104,6 +154,23 @@ Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: <shared_secr
}
```
### Chat messages
You can also send chat envelopes over the same WebSocket to display messages in the browser. Fields:
- `type`: must be "chat"
- `character_name`: target player name
- `text`: message content
- `color` (optional): CSS color string (e.g. "#ff8800"); if sent as an integer (0xRRGGBB), it will be converted to hex.
Example chat payload:
```json
{
"type": "chat",
"character_name": "MyCharacter",
"text": "Hello world!",
"color": "#88f"
}
```
### GET /live
Returns active players seen within the last 30 seconds:
@ -131,17 +198,12 @@ Response:
## Frontend
- **Live Map**: `static/index.html` Real-time player positions on a map.
- **Analytics Dashboard**: `static/graphs.html` Interactive charts powered by [D3.js](https://d3js.org/).
## Database Schema
- **telemetry_log**: Stored history of snapshots.
- **live_state**: Current snapshot per character (upserted).
## Sample Payload
See `test.json` for an example telemetry snapshot.
## Contributing
Contributions are welcome! Feel free to open issues or submit pull requests.

38
db.py
View file

@ -1,13 +1,38 @@
import os
import sqlite3
from typing import Dict
from datetime import datetime, timedelta
DB_FILE = "dereth.db"
# Maximum allowed database size (in MB). Defaults to 2048 (2GB). Override via env DB_MAX_SIZE_MB.
MAX_DB_SIZE_MB = int(os.getenv("DB_MAX_SIZE_MB", "2048"))
# Retention window for telemetry history in days. Override via env DB_RETENTION_DAYS.
MAX_RETENTION_DAYS = int(os.getenv("DB_RETENTION_DAYS", "7"))
# SQLite runtime limits customization
DB_MAX_SQL_LENGTH = int(os.getenv("DB_MAX_SQL_LENGTH", "1000000000"))
DB_MAX_SQL_VARIABLES = int(os.getenv("DB_MAX_SQL_VARIABLES", "32766"))
# Number of WAL frames to write before forcing a checkpoint (override via env DB_WAL_AUTOCHECKPOINT_PAGES)
DB_WAL_AUTOCHECKPOINT_PAGES = int(os.getenv("DB_WAL_AUTOCHECKPOINT_PAGES", "1000"))
def init_db() -> None:
"""Create tables if they do not exist (extended with kills_per_hour and onlinetime)."""
conn = sqlite3.connect(DB_FILE)
# Open connection with a longer timeout
conn = sqlite3.connect(DB_FILE, timeout=30)
# Bump SQLite runtime limits
conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, DB_MAX_SQL_LENGTH)
conn.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, DB_MAX_SQL_LENGTH)
conn.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, DB_MAX_SQL_VARIABLES)
c = conn.cursor()
# Enable auto_vacuum FULL and rebuild DB so that deletions shrink the file
c.execute("PRAGMA auto_vacuum=FULL;")
conn.commit()
c.execute("VACUUM;")
conn.commit()
# Switch to WAL mode for concurrency, adjust checkpointing, and enforce max size
c.execute("PRAGMA journal_mode=WAL")
c.execute("PRAGMA synchronous=NORMAL")
c.execute(f"PRAGMA wal_autocheckpoint={DB_WAL_AUTOCHECKPOINT_PAGES}")
# History log
c.execute(
@ -60,8 +85,17 @@ def init_db() -> None:
def save_snapshot(data: Dict) -> None:
"""Insert snapshot into history and upsert into live_state (with new fields)."""
conn = sqlite3.connect(DB_FILE)
# Open connection with a longer busy timeout
conn = sqlite3.connect(DB_FILE, timeout=30)
# Bump SQLite runtime limits on this connection
conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, DB_MAX_SQL_LENGTH)
conn.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, DB_MAX_SQL_LENGTH)
conn.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, DB_MAX_SQL_VARIABLES)
c = conn.cursor()
# Ensure WAL mode, checkpointing, and size limit on this connection
c.execute("PRAGMA journal_mode=WAL")
c.execute("PRAGMA synchronous=NORMAL")
c.execute(f"PRAGMA wal_autocheckpoint={DB_WAL_AUTOCHECKPOINT_PAGES}")
# Insert full history row
c.execute(

22
docker-compose.yml Normal file
View file

@ -0,0 +1,22 @@
version: "3.8"
services:
dereth-tracker:
build: .
ports:
- "127.0.0.1:8765:8765"
volumes:
# Mount local database file for persistence
- "./dereth.db:/app/dereth.db"
- "./main.py:/app/main.py"
- "./db.py:/app/db.py"
- "./static:/app/static"
environment:
# Override database and application settings as needed
DB_MAX_SIZE_MB: "2048"
DB_RETENTION_DAYS: "7"
DB_MAX_SQL_LENGTH: "1000000000"
DB_MAX_SQL_VARIABLES: "32766"
DB_WAL_AUTOCHECKPOINT_PAGES: "1000"
SHARED_SECRET: "your_shared_secret"
restart: unless-stopped

View file

@ -1,45 +1,45 @@
import httpx
import asyncio
import websockets
import json
from datetime import datetime, timedelta, timezone
from time import sleep
from main import TelemetrySnapshot
def main() -> None:
async def main() -> None:
wait = 10
online_time = 24 * 3600 # start at 1 day
ew = 0
ns = 0
while True:
snapshot = TelemetrySnapshot(
character_name="Test name",
char_tag="test_tag",
session_id="test_session_id",
timestamp=datetime.now(tz=timezone.utc),
ew=ew,
ns=ns,
z=0,
kills=0,
kills_per_hour="kph_str",
onlinetime=str(timedelta(seconds=online_time)),
deaths=0,
rares_found=0,
prismatic_taper_count=0,
vt_state="test state",
)
resp = httpx.post(
"http://localhost:8000/position/",
data=snapshot.model_dump_json(),
headers={
"Content-Type": "application/json",
"X-PLUGIN-SECRET": "your_shared_secret",
},
)
print(resp)
sleep(wait)
ew += 0.1
ns += 0.1
online_time += wait
ew = 0.0
ns = 0.0
uri = "ws://localhost:8000/ws/position?secret=your_shared_secret"
async with websockets.connect(uri) as websocket:
print(f"Connected to {uri}")
while True:
snapshot = TelemetrySnapshot(
character_name="Test name",
char_tag="test_tag",
session_id="test_session_id",
timestamp=datetime.now(tz=timezone.utc),
ew=ew,
ns=ns,
z=0,
kills=0,
kills_per_hour="kph_str",
onlinetime=str(timedelta(seconds=online_time)),
deaths=0,
rares_found=0,
prismatic_taper_count=0,
vt_state="test state",
)
# wrap in envelope with message type
payload = snapshot.model_dump()
payload["type"] = "telemetry"
await websocket.send(json.dumps(payload, default=str))
print(f"Sent snapshot: EW={ew:.2f}, NS={ns:.2f}")
await asyncio.sleep(wait)
ew += 0.1
ns += 0.1
online_time += wait
if __name__ == "__main__":
main()
asyncio.run(main())

161
main.py
View file

@ -7,6 +7,7 @@ from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketD
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from fastapi.staticfiles import StaticFiles
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from typing import Optional
@ -16,7 +17,7 @@ from starlette.concurrency import run_in_threadpool
# ------------------------------------------------------------------
app = FastAPI()
# test
# In-memory store of the last packet per character
live_snapshots: Dict[str, dict] = {}
@ -50,31 +51,6 @@ 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")
@ -82,22 +58,33 @@ def debug():
return {"status": "OK"}
@app.get("/live")
@app.get("/live/")
@app.get("/live", response_model=dict)
@app.get("/live/", response_model=dict)
def get_live_players():
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
rows = conn.execute("SELECT * FROM live_state").fetchall()
conn.close()
# compute cutoff once
now_utc = datetime.now(timezone.utc)
cutoff = now_utc - ACTIVE_WINDOW
# aware cutoff (UTC)
cutoff = datetime.utcnow().replace(tzinfo=timezone.utc) - ACTIVE_WINDOW
cutoff_sql = cutoff.strftime("%Y-%m-%d %H:%M:%S")
players = [
dict(r)
for r in rows
if datetime.fromisoformat(r["timestamp"].replace("Z", "+00:00")) > cutoff
]
try:
with sqlite3.connect(DB_FILE) as conn:
conn.row_factory = sqlite3.Row
query = """
SELECT *
FROM live_state
WHERE datetime(timestamp) > datetime(?, 'utc')
"""
rows = conn.execute(query, (cutoff_sql,)).fetchall()
except sqlite3.Error as e:
# log e if you have logging set up
raise HTTPException(status_code=500, detail="Database error")
# build list of dicts
players = []
for r in rows:
players.append(dict(r))
return JSONResponse(content={"players": players})
@ -198,42 +185,114 @@ def get_trails(
# -------------------- WebSocket endpoints -----------------------
browser_conns: set[WebSocket] = set()
# Map of registered plugin clients: character_name -> WebSocket
plugin_conns: Dict[str, WebSocket] = {}
async def _broadcast_to_browser_clients(snapshot: dict):
# Ensure all data (e.g. datetime) is JSON-serializable
data = jsonable_encoder(snapshot)
for ws in list(browser_conns):
try:
await ws.send_json(snapshot)
await ws.send_json(data)
except WebSocketDisconnect:
browser_conns.remove(ws)
@app.websocket("/ws/position")
async def ws_receive_snapshots(websocket: WebSocket, secret: str = Query(...)):
await websocket.accept()
if secret != SHARED_SECRET:
async def ws_receive_snapshots(
websocket: WebSocket,
secret: str | None = Query(None),
x_plugin_secret: str | None = Header(None)
):
# Verify shared secret from query parameter or header
key = secret or x_plugin_secret
if key != SHARED_SECRET:
# Reject without completing the WebSocket handshake
await websocket.close(code=1008)
return
# Accept the WebSocket connection
await websocket.accept()
print(f"[WS] Plugin connected: {websocket.client}")
try:
while True:
data = await websocket.receive_json()
snap = TelemetrySnapshot.parse_obj(data)
live_snapshots[snap.character_name] = snap.dict()
await run_in_threadpool(save_snapshot, snap.dict())
await _broadcast_to_browser_clients(snap.dict())
except WebSocketDisconnect:
pass
# Read next text frame
try:
raw = await websocket.receive_text()
# Debug: log all incoming plugin WebSocket messages
print(f"[WS-PLUGIN RX] {websocket.client}: {raw}")
except WebSocketDisconnect:
print(f"[WS] Plugin disconnected: {websocket.client}")
break
# Parse JSON payload
try:
data = json.loads(raw)
except json.JSONDecodeError:
continue
msg_type = data.get("type")
# Registration message: map character to this socket
if msg_type == "register":
name = data.get("character_name") or data.get("player_name")
if isinstance(name, str):
plugin_conns[name] = websocket
continue
# Telemetry message: save to DB and broadcast
if msg_type == "telemetry":
payload = data.copy()
payload.pop("type", None)
snap = TelemetrySnapshot.parse_obj(payload)
live_snapshots[snap.character_name] = snap.dict()
await run_in_threadpool(save_snapshot, snap.dict())
await _broadcast_to_browser_clients(snap.dict())
continue
# Chat message: broadcast to browser clients only (no DB write)
if msg_type == "chat":
await _broadcast_to_browser_clients(data)
continue
# Unknown message types are ignored
finally:
# Clean up any plugin registrations for this socket
to_remove = [n for n, ws in plugin_conns.items() if ws is websocket]
for n in to_remove:
del plugin_conns[n]
print(f"[WS] Cleaned up plugin connections for {websocket.client}")
@app.websocket("/ws/live")
async def ws_live_updates(websocket: WebSocket):
# Browser clients connect here to receive telemetry and chat, and send commands
await websocket.accept()
browser_conns.add(websocket)
try:
while True:
await asyncio.sleep(3600)
# Receive command messages from browser
try:
data = await websocket.receive_json()
# Debug: log all incoming browser WebSocket messages
print(f"[WS-LIVE RX] {websocket.client}: {data}")
except WebSocketDisconnect:
break
# Determine command envelope format (new or legacy)
if "player_name" in data and "command" in data:
# New format: { player_name, command }
target_name = data["player_name"]
payload = data
elif data.get("type") == "command" and "character_name" in data and "text" in data:
# Legacy format: { type: 'command', character_name, text }
target_name = data.get("character_name")
payload = {"player_name": target_name, "command": data.get("text")}
else:
# Not a recognized command envelope
continue
# Forward command envelope to the appropriate plugin WebSocket
target_ws = plugin_conns.get(target_name)
if target_ws:
await target_ws.send_json(payload)
except WebSocketDisconnect:
pass
finally:
browser_conns.remove(websocket)
# -------------------- static frontend ---------------------------
# static frontend
app.mount("/", StaticFiles(directory="static", html=True), name="static")
# list routes for convenience

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View file

@ -8,6 +8,11 @@ const list = document.getElementById('playerList');
const btnContainer = document.getElementById('sortButtons');
const tooltip = document.getElementById('tooltip');
// WebSocket for chat and commands
let socket;
// Keep track of open chat windows: character_name -> DOM element
const chatWindows = {};
/* ---------- constants ------------------------------------------- */
const MAX_Z = 10;
const FOCUS_ZOOM = 3; // zoom level when you click a name
@ -19,6 +24,44 @@ const MAP_BOUNDS = {
south: -102.00
};
// Base path for tracker API endpoints; prefix API calls with '/api' when served behind a proxy
const API_BASE = '/api';
// Maximum number of lines to retain in each chat window scrollback
const MAX_CHAT_LINES = 1000;
// Map numeric chat color codes to CSS hex colors
const CHAT_COLOR_MAP = {
0: '#00FF00', // Broadcast
2: '#FFFFFF', // Speech
3: '#FFD700', // Tell
4: '#CCCC00', // OutgoingTell
5: '#FF00FF', // System
6: '#FF0000', // Combat
7: '#00CCFF', // Magic
8: '#DDDDDD', // Channel
9: '#FF9999', // ChannelSend
10: '#FFFF33', // Social
11: '#CCFF33', // SocialSend
12: '#FFFFFF', // Emote
13: '#00FFFF', // Advancement
14: '#66CCFF', // Abuse
15: '#FF0000', // Help
16: '#33FF00', // Appraisal
17: '#0099FF', // Spellcasting
18: '#FF6600', // Allegiance
19: '#CC66FF', // Fellowship
20: '#00FF00', // WorldBroadcast
21: '#FF0000', // CombatEnemy
22: '#FF33CC', // CombatSelf
23: '#00CC00', // Recall
24: '#00FF00', // Craft
25: '#00FF66', // Salvaging
27: '#FFFFFF', // General
28: '#33FF33', // Trade
29: '#CCCCCC', // LFG
30: '#CC00CC', // Roleplay
31: '#FFFF00' // AdminTell
};
/* ---------- sort configuration ---------------------------------- */
const sortOptions = [
{
@ -132,8 +175,8 @@ function hideTooltip() {
async function pollLive() {
try {
const [liveRes, trailsRes] = await Promise.all([
fetch('/live/'),
fetch('/trails/?seconds=600'),
fetch(`${API_BASE}/live/`),
fetch(`${API_BASE}/trails/?seconds=600`),
]);
const { players } = await liveRes.json();
const { trails } = await trailsRes.json();
@ -162,6 +205,7 @@ img.onload = () => {
}
fitToWindow();
startPolling();
initWebSocket();
};
/* ---------- rendering sorted list & dots ------------------------ */
@ -212,9 +256,30 @@ function render(players) {
<span class="stat onlinetime">${p.onlinetime}</span>
<span class="stat deaths">${p.deaths}</span>
`;
// Color the metastate pill according to its value
const metaSpan = li.querySelector('.stat.meta');
if (metaSpan) {
const goodStates = ['default', 'default2', 'hunt', 'combat'];
const state = (p.vt_state || '').toString().toLowerCase();
if (goodStates.includes(state)) {
metaSpan.classList.add('green');
} else {
metaSpan.classList.add('red');
}
}
li.addEventListener('click', () => selectPlayer(p, x, y));
if (p.character_name === selected) li.classList.add('selected');
// Chat button
const chatBtn = document.createElement('button');
chatBtn.className = 'chat-btn';
chatBtn.textContent = 'Chat';
chatBtn.addEventListener('click', e => {
e.stopPropagation();
showChatWindow(p.character_name);
});
li.appendChild(chatBtn);
list.appendChild(li);
});
}
@ -253,6 +318,178 @@ function selectPlayer(p, x, y) {
renderList(); // keep sorted + highlight
}
/* ---------- chat & command handlers ---------------------------- */
// Initialize WebSocket for chat and commands
function initWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}${API_BASE}/ws/live`;
socket = new WebSocket(wsUrl);
socket.addEventListener('message', evt => {
let msg;
try { msg = JSON.parse(evt.data); } catch { return; }
if (msg.type === 'chat') {
appendChatMessage(msg);
}
});
socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
socket.addEventListener('error', e => console.error('WebSocket error:', e));
}
// Display or create a chat window for a character
function showChatWindow(name) {
if (chatWindows[name]) {
// Restore flex layout when reopening & bring to front
const existing = chatWindows[name];
existing.style.display = 'flex';
if (!window.__chatZ) window.__chatZ = 10000;
window.__chatZ += 1;
existing.style.zIndex = window.__chatZ;
return;
}
const win = document.createElement('div');
win.className = 'chat-window';
win.dataset.character = name;
// Header
const header = document.createElement('div');
header.className = 'chat-header';
const title = document.createElement('span');
title.textContent = `Chat: ${name}`;
const closeBtn = document.createElement('button');
closeBtn.className = 'chat-close-btn';
closeBtn.textContent = '×';
closeBtn.addEventListener('click', () => { win.style.display = 'none'; });
header.appendChild(title);
header.appendChild(closeBtn);
win.appendChild(header);
// Messages container
const msgs = document.createElement('div');
msgs.className = 'chat-messages';
win.appendChild(msgs);
// Input form
const form = document.createElement('form');
form.className = 'chat-form';
const input = document.createElement('input');
input.type = 'text';
input.className = 'chat-input';
input.placeholder = 'Enter chat...';
form.appendChild(input);
form.addEventListener('submit', e => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
// Send command envelope: player_name and command only
socket.send(JSON.stringify({ player_name: name, command: text }));
input.value = '';
});
win.appendChild(form);
document.body.appendChild(win);
chatWindows[name] = win;
/* --------------------------------------------------------- */
/* enable dragging of the chat window via its header element */
/* --------------------------------------------------------- */
// keep a static counter so newer windows can be brought to front
if (!window.__chatZ) window.__chatZ = 10000;
let drag = false;
let startX = 0, startY = 0;
let startLeft = 0, startTop = 0;
header.style.cursor = 'move';
// bring to front when interacting
const bringToFront = () => {
window.__chatZ += 1;
win.style.zIndex = window.__chatZ;
};
header.addEventListener('mousedown', e => {
// don't initiate drag when pressing the close button (or other clickable controls)
if (e.target.closest('button')) return;
e.preventDefault();
drag = true;
bringToFront();
startX = e.clientX;
startY = e.clientY;
// current absolute position
startLeft = win.offsetLeft;
startTop = win.offsetTop;
document.body.classList.add('noselect');
});
window.addEventListener('mousemove', e => {
if (!drag) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
win.style.left = `${startLeft + dx}px`;
win.style.top = `${startTop + dy}px`;
});
window.addEventListener('mouseup', () => {
drag = false;
document.body.classList.remove('noselect');
});
/* touch support */
header.addEventListener('touchstart', e => {
if (e.touches.length !== 1 || e.target.closest('button')) return;
drag = true;
bringToFront();
const t = e.touches[0];
startX = t.clientX;
startY = t.clientY;
startLeft = win.offsetLeft;
startTop = win.offsetTop;
});
window.addEventListener('touchmove', e => {
if (!drag || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
win.style.left = `${startLeft + dx}px`;
win.style.top = `${startTop + dy}px`;
});
window.addEventListener('touchend', () => {
drag = false;
});
}
// Append a chat message to the correct window
/**
* Append a chat message to the correct window, optionally coloring the text.
* msg: { type: 'chat', character_name, text, color? }
*/
function appendChatMessage(msg) {
const { character_name: name, text, color } = msg;
const win = chatWindows[name];
if (!win) return;
const msgs = win.querySelector('.chat-messages');
const p = document.createElement('div');
if (color !== undefined) {
let c = color;
if (typeof c === 'number') {
// map numeric chat code to configured color, or fallback to raw hex
if (CHAT_COLOR_MAP.hasOwnProperty(c)) {
c = CHAT_COLOR_MAP[c];
} else {
c = '#' + c.toString(16).padStart(6, '0');
}
}
p.style.color = c;
}
p.textContent = text;
msgs.appendChild(p);
// Enforce max number of lines in scrollback
while (msgs.children.length > MAX_CHAT_LINES) {
msgs.removeChild(msgs.firstChild);
}
// Scroll to bottom
msgs.scrollTop = msgs.scrollHeight;
}
/* ---------- pan & zoom handlers -------------------------------- */
wrap.addEventListener('wheel', e => {
e.preventDefault();

View file

@ -7,6 +7,11 @@
--text: #eee;
--accent: #88f;
}
/* Placeholder text in chat input should be white */
.chat-input::placeholder {
color: #fff;
opacity: 0.7;
}
html {
margin: 0;
@ -197,10 +202,103 @@ body {
.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 {
/* metastate pill colors are assigned dynamically: green for “good” states, red otherwise */
#playerList li .stat.meta {
/* fallback */
background: var(--accent);
color: #111;
}
#playerList li .stat.meta.green {
background: #2ecc71; /* pleasant green */
color: #111;
}
#playerList li .stat.meta.red {
background: #e74c3c; /* vivid red */
color: #fff;
}
/* ---------- chat window styling ------------------------------- */
.chat-btn {
margin-top: 4px;
padding: 2px 6px;
background: var(--accent);
color: #111;
border: none;
border-radius: 3px;
font-size: 0.75rem;
cursor: pointer;
}
.chat-window {
position: absolute;
top: 10px;
/* position window to start just right of the sidebar */
left: calc(var(--sidebar-width) + 10px);
/* increase default size for better usability */
width: 760px; /* increased width for larger terminal area */
height: 300px;
background: var(--card);
border: 1px solid #555;
display: flex;
flex-direction: column;
z-index: 10000;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--accent);
padding: 4px;
color: #111;
cursor: move; /* indicates the header is draggable */
}
.chat-close-btn {
background: transparent;
border: none;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 4px;
font-size: 0.85rem;
color: #fff;
/* reserve space so messages aren't hidden behind the input */
padding-bottom: 40px;
}
.chat-form {
display: flex;
border-top: 1px solid #333;
/* fix input area to the bottom of the chat window */
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: #333;
z-index: 10;
}
.chat-input {
flex: 1;
padding: 4px 6px;
border: none;
background: #333;
color: #fff;
outline: none;
}
/* Prevent text selection while dragging chat windows */
body.noselect, body.noselect * {
user-select: none !important;
}
.stat.onlinetime::before { content: "🕑 "}
.stat.deaths::before { content: "💀 "}