ws version with nice DB select
This commit is contained in:
parent
a121d57a13
commit
73ae756e5c
6 changed files with 491 additions and 106 deletions
94
README.md
94
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 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
38
db.py
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
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
|
||||
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",
|
||||
|
|
@ -26,20 +30,16 @@ def main() -> None:
|
|||
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)
|
||||
# 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())
|
||||
|
|
|
|||
151
main.py
151
main.py
|
|
@ -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
|
||||
|
||||
|
|
@ -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)
|
||||
# compute cutoff once
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
cutoff = now_utc - ACTIVE_WINDOW
|
||||
|
||||
cutoff_sql = cutoff.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
try:
|
||||
with sqlite3.connect(DB_FILE) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute("SELECT * FROM live_state").fetchall()
|
||||
conn.close()
|
||||
query = """
|
||||
SELECT *
|
||||
FROM live_state
|
||||
WHERE datetime(timestamp) > datetime(?, 'utc')
|
||||
"""
|
||||
rows = conn.execute(query, (cutoff_sql,)).fetchall()
|
||||
|
||||
# aware cutoff (UTC)
|
||||
cutoff = datetime.utcnow().replace(tzinfo=timezone.utc) - ACTIVE_WINDOW
|
||||
except sqlite3.Error as e:
|
||||
# log e if you have logging set up
|
||||
raise HTTPException(status_code=500, detail="Database error")
|
||||
|
||||
players = [
|
||||
dict(r)
|
||||
for r in rows
|
||||
if datetime.fromisoformat(r["timestamp"].replace("Z", "+00:00")) > cutoff
|
||||
]
|
||||
# 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)
|
||||
# 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())
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
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
|
||||
|
|
|
|||
154
static/script.js
154
static/script.js
|
|
@ -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 ------------------------ */
|
||||
|
|
@ -215,6 +259,15 @@ function render(players) {
|
|||
|
||||
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 +306,103 @@ 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
|
||||
chatWindows[name].style.display = 'flex';
|
||||
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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -201,6 +206,81 @@ body {
|
|||
background: var(--accent);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* ---------- 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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.stat.onlinetime::before { content: "🕑 "}
|
||||
.stat.deaths::before { content: "💀 "}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue