diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index be2c14f1..00000000 --- a/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -# 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"] diff --git a/FIXES.md b/FIXES.md deleted file mode 100644 index c8160b85..00000000 --- a/FIXES.md +++ /dev/null @@ -1,48 +0,0 @@ -# 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 Asheron’s 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._ \ No newline at end of file diff --git a/LESSONSLEARNED.md b/LESSONSLEARNED.md deleted file mode 100644 index 2ee1085d..00000000 --- a/LESSONSLEARNED.md +++ /dev/null @@ -1,38 +0,0 @@ -# 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] : ` for messages on `/ws/position` - - `[WS-LIVE RX] : ` 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 FastAPI’s `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. \ No newline at end of file diff --git a/README.md b/README.md index 50a7849d..a4d1aaca 100644 --- a/README.md +++ b/README.md @@ -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 a live map interface along with a sample data generator for testing. +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. ## Table of Contents - [Overview](#overview) @@ -12,6 +12,7 @@ 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 @@ -20,16 +21,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. -- A sample data generator script (`generate_data.py`) for simulating telemetry snapshots. +- An analytics dashboard for visualizing kills and session metrics. ## Features -- **WebSocket /ws/position**: Stream telemetry snapshots (protected by a shared secret). +- **POST /position**: Submit a telemetry snapshot (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. -- **Sample Data Generator**: `generate_data.py` sends telemetry snapshots over WebSocket for testing. + - **Analytics Dashboard**: Interactive charts for kills over time and kills per hour using D3.js. ## Requirements @@ -44,7 +45,6 @@ Python packages: - pydantic - pandas - matplotlib -- websockets # required for sample data generator ## Installation @@ -60,14 +60,13 @@ Python packages: ``` 3. Install dependencies: ```bash - pip install fastapi uvicorn pydantic pandas matplotlib websockets + pip install fastapi uvicorn pydantic pandas matplotlib ``` ## 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 @@ -77,66 +76,17 @@ Start the server using Uvicorn: uvicorn main:app --reload --host 0.0.0.0 --port 8000 ``` -## 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:///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] : ` for plugin messages on `/ws/position` - - `[WS-LIVE RX] : ` 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; }`. +- Live Map: `http://localhost:8000/` +- Analytics Dashboard: `http://localhost:8000/graphs.html` ## API Reference -### WebSocket /ws/position -Stream telemetry snapshots over a WebSocket connection. Provide your shared secret either as a query parameter or WebSocket header: - -``` -ws://:/ws/position?secret= -``` -or -``` -X-Plugin-Secret: -``` - -After connecting, send JSON messages matching the `TelemetrySnapshot` schema. For example: +### POST /position +Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: `. +**Request Body Example:** ```json { - "type": "telemetry", "character_name": "Dunking Rares", "char_tag": "moss", "session_id": "dunk-20250422-xyz", @@ -154,23 +104,6 @@ After connecting, send JSON messages matching the `TelemetrySnapshot` schema. Fo } ``` -### 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: @@ -198,12 +131,17 @@ 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. diff --git a/db.py b/db.py index 9381932a..91e97369 100644 --- a/db.py +++ b/db.py @@ -1,38 +1,13 @@ -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).""" - # 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) + conn = sqlite3.connect(DB_FILE) 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( @@ -85,17 +60,8 @@ def init_db() -> None: def save_snapshot(data: Dict) -> None: """Insert snapshot into history and upsert into live_state (with new fields).""" - # 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) + conn = sqlite3.connect(DB_FILE) 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( diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 013a91f6..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,22 +0,0 @@ -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 \ No newline at end of file diff --git a/generate_data.py b/generate_data.py index d5eb674f..fdbbfd1a 100644 --- a/generate_data.py +++ b/generate_data.py @@ -1,45 +1,45 @@ -import asyncio -import websockets -import json +import httpx from datetime import datetime, timedelta, timezone +from time import sleep from main import TelemetrySnapshot -async def main() -> None: +def main() -> None: wait = 10 online_time = 24 * 3600 # start at 1 day - 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 + 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 if __name__ == "__main__": - asyncio.run(main()) + main() diff --git a/main.py b/main.py index f5942551..fdb48928 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,6 @@ 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 @@ -17,7 +16,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] = {} @@ -51,6 +50,31 @@ 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") @@ -58,33 +82,22 @@ def debug(): return {"status": "OK"} -@app.get("/live", response_model=dict) -@app.get("/live/", response_model=dict) +@app.get("/live") +@app.get("/live/") def get_live_players(): - # compute cutoff once - now_utc = datetime.now(timezone.utc) - cutoff = now_utc - ACTIVE_WINDOW + conn = sqlite3.connect(DB_FILE) + conn.row_factory = sqlite3.Row + rows = conn.execute("SELECT * FROM live_state").fetchall() + conn.close() - cutoff_sql = cutoff.strftime("%Y-%m-%d %H:%M:%S") + # aware cutoff (UTC) + cutoff = datetime.utcnow().replace(tzinfo=timezone.utc) - ACTIVE_WINDOW - 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)) + players = [ + dict(r) + for r in rows + if datetime.fromisoformat(r["timestamp"].replace("Z", "+00:00")) > cutoff + ] return JSONResponse(content={"players": players}) @@ -185,114 +198,42 @@ 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(data) + await ws.send_json(snapshot) except WebSocketDisconnect: browser_conns.remove(ws) @app.websocket("/ws/position") -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 +async def ws_receive_snapshots(websocket: WebSocket, secret: str = Query(...)): + await websocket.accept() + if secret != SHARED_SECRET: await websocket.close(code=1008) return - # Accept the WebSocket connection - await websocket.accept() - print(f"[WS] Plugin connected: {websocket.client}") try: while True: - # 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}") + 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 @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: - # 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) + await asyncio.sleep(3600) 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 diff --git a/static/favicon.ico b/static/favicon.ico deleted file mode 100644 index 3be83f80..00000000 Binary files a/static/favicon.ico and /dev/null differ diff --git a/static/script.js b/static/script.js index 499c3855..bfe94b72 100644 --- a/static/script.js +++ b/static/script.js @@ -8,11 +8,6 @@ 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 @@ -24,44 +19,6 @@ 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 = [ { @@ -175,8 +132,8 @@ function hideTooltip() { async function pollLive() { try { const [liveRes, trailsRes] = await Promise.all([ - fetch(`${API_BASE}/live/`), - fetch(`${API_BASE}/trails/?seconds=600`), + fetch('/live/'), + fetch('/trails/?seconds=600'), ]); const { players } = await liveRes.json(); const { trails } = await trailsRes.json(); @@ -205,7 +162,6 @@ img.onload = () => { } fitToWindow(); startPolling(); - initWebSocket(); }; /* ---------- rendering sorted list & dots ------------------------ */ @@ -256,30 +212,9 @@ function render(players) { ${p.onlinetime} ${p.deaths} `; - - // 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); }); } @@ -318,178 +253,6 @@ 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(); diff --git a/static/style.css b/static/style.css index ee615d48..b6a7286d 100644 --- a/static/style.css +++ b/static/style.css @@ -7,11 +7,6 @@ --text: #eee; --accent: #88f; } -/* Placeholder text in chat input should be white */ -.chat-input::placeholder { - color: #fff; - opacity: 0.7; -} html { margin: 0; @@ -202,103 +197,10 @@ 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; } -/* metastate pill colors are assigned dynamically: green for “good” states, red otherwise */ -#playerList li .stat.meta { - /* fallback */ +.stat.meta { 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: "💀 "}