ws version with nice DB select

This commit is contained in:
erik 2025-05-09 22:35:41 +00:00
parent a121d57a13
commit 73ae756e5c
6 changed files with 491 additions and 106 deletions

View file

@ -1,6 +1,6 @@
# Dereth Tracker # 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 ## Table of Contents
- [Overview](#overview) - [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) - [API Reference](#api-reference)
- [Frontend](#frontend) - [Frontend](#frontend)
- [Database Schema](#database-schema) - [Database Schema](#database-schema)
- [Sample Payload](#sample-payload)
- [Contributing](#contributing) - [Contributing](#contributing)
## Overview ## Overview
@ -21,16 +20,16 @@ This project provides:
- A FastAPI backend with endpoints for receiving and querying telemetry data. - A FastAPI backend with endpoints for receiving and querying telemetry data.
- SQLite-based storage for snapshots and live state. - SQLite-based storage for snapshots and live state.
- A live, interactive map using static HTML, CSS, and JavaScript. - 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 ## 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 /live**: Fetch active players seen in the last 30 seconds.
- **GET /history**: Retrieve historical telemetry data with optional time filtering. - **GET /history**: Retrieve historical telemetry data with optional time filtering.
- **GET /debug**: Health check endpoint. - **GET /debug**: Health check endpoint.
- **Live Map**: Interactive map interface with panning, zooming, and sorting. - **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 ## Requirements
@ -45,6 +44,7 @@ Python packages:
- pydantic - pydantic
- pandas - pandas
- matplotlib - matplotlib
- websockets # required for sample data generator
## Installation ## Installation
@ -60,13 +60,14 @@ Python packages:
``` ```
3. Install dependencies: 3. Install dependencies:
```bash ```bash
pip install fastapi uvicorn pydantic pandas matplotlib pip install fastapi uvicorn pydantic pandas matplotlib websockets
``` ```
## Configuration ## Configuration
- Update the `SHARED_SECRET` in `main.py` to match your plugin (default: `"your_shared_secret"`). - 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`. - 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 ## Usage
@ -76,17 +77,66 @@ Start the server using Uvicorn:
uvicorn main:app --reload --host 0.0.0.0 --port 8000 uvicorn main:app --reload --host 0.0.0.0 --port 8000
``` ```
- Live Map: `http://localhost:8000/` ## NGINX Proxy Configuration
- Analytics Dashboard: `http://localhost:8000/graphs.html`
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 ## API Reference
### POST /position ### WebSocket /ws/position
Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: <shared_secret>`. 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 ```json
{ {
"type": "telemetry",
"character_name": "Dunking Rares", "character_name": "Dunking Rares",
"char_tag": "moss", "char_tag": "moss",
"session_id": "dunk-20250422-xyz", "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 ### GET /live
Returns active players seen within the last 30 seconds: Returns active players seen within the last 30 seconds:
@ -131,17 +198,12 @@ Response:
## Frontend ## Frontend
- **Live Map**: `static/index.html` Real-time player positions on a map. - **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 ## Database Schema
- **telemetry_log**: Stored history of snapshots. - **telemetry_log**: Stored history of snapshots.
- **live_state**: Current snapshot per character (upserted). - **live_state**: Current snapshot per character (upserted).
## Sample Payload
See `test.json` for an example telemetry snapshot.
## Contributing ## Contributing
Contributions are welcome! Feel free to open issues or submit pull requests. 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 import sqlite3
from typing import Dict from typing import Dict
from datetime import datetime, timedelta
DB_FILE = "dereth.db" 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: def init_db() -> None:
"""Create tables if they do not exist (extended with kills_per_hour and onlinetime).""" """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() 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 # History log
c.execute( c.execute(
@ -60,8 +85,17 @@ def init_db() -> None:
def save_snapshot(data: Dict) -> None: def save_snapshot(data: Dict) -> None:
"""Insert snapshot into history and upsert into live_state (with new fields).""" """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() 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 # Insert full history row
c.execute( c.execute(

View file

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

159
main.py
View file

@ -7,6 +7,7 @@ from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketD
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
@ -50,31 +51,6 @@ def on_startup():
init_db() 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 ----------------------------------- # ------------------------ GET -----------------------------------
@app.get("/debug") @app.get("/debug")
@ -82,22 +58,33 @@ def debug():
return {"status": "OK"} return {"status": "OK"}
@app.get("/live") @app.get("/live", response_model=dict)
@app.get("/live/") @app.get("/live/", response_model=dict)
def get_live_players(): def get_live_players():
conn = sqlite3.connect(DB_FILE) # compute cutoff once
conn.row_factory = sqlite3.Row now_utc = datetime.now(timezone.utc)
rows = conn.execute("SELECT * FROM live_state").fetchall() cutoff = now_utc - ACTIVE_WINDOW
conn.close()
# aware cutoff (UTC) cutoff_sql = cutoff.strftime("%Y-%m-%d %H:%M:%S")
cutoff = datetime.utcnow().replace(tzinfo=timezone.utc) - ACTIVE_WINDOW
players = [ try:
dict(r) with sqlite3.connect(DB_FILE) as conn:
for r in rows conn.row_factory = sqlite3.Row
if datetime.fromisoformat(r["timestamp"].replace("Z", "+00:00")) > cutoff 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}) return JSONResponse(content={"players": players})
@ -198,42 +185,114 @@ def get_trails(
# -------------------- WebSocket endpoints ----------------------- # -------------------- WebSocket endpoints -----------------------
browser_conns: set[WebSocket] = set() 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): 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): for ws in list(browser_conns):
try: try:
await ws.send_json(snapshot) await ws.send_json(data)
except WebSocketDisconnect: except WebSocketDisconnect:
browser_conns.remove(ws) browser_conns.remove(ws)
@app.websocket("/ws/position") @app.websocket("/ws/position")
async def ws_receive_snapshots(websocket: WebSocket, secret: str = Query(...)): async def ws_receive_snapshots(
await websocket.accept() websocket: WebSocket,
if secret != SHARED_SECRET: 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) await websocket.close(code=1008)
return return
# Accept the WebSocket connection
await websocket.accept()
print(f"[WS] Plugin connected: {websocket.client}")
try: try:
while True: while True:
data = await websocket.receive_json() # Read next text frame
snap = TelemetrySnapshot.parse_obj(data) try:
live_snapshots[snap.character_name] = snap.dict() raw = await websocket.receive_text()
await run_in_threadpool(save_snapshot, snap.dict()) # Debug: log all incoming plugin WebSocket messages
await _broadcast_to_browser_clients(snap.dict()) print(f"[WS-PLUGIN RX] {websocket.client}: {raw}")
except WebSocketDisconnect: except WebSocketDisconnect:
pass 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") @app.websocket("/ws/live")
async def ws_live_updates(websocket: WebSocket): async def ws_live_updates(websocket: WebSocket):
# Browser clients connect here to receive telemetry and chat, and send commands
await websocket.accept() await websocket.accept()
browser_conns.add(websocket) browser_conns.add(websocket)
try: try:
while True: 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: except WebSocketDisconnect:
pass
finally:
browser_conns.remove(websocket) browser_conns.remove(websocket)
# -------------------- static frontend --------------------------- # -------------------- static frontend ---------------------------
# static frontend
app.mount("/", StaticFiles(directory="static", html=True), name="static") app.mount("/", StaticFiles(directory="static", html=True), name="static")
# list routes for convenience # list routes for convenience

View file

@ -8,6 +8,11 @@ const list = document.getElementById('playerList');
const btnContainer = document.getElementById('sortButtons'); const btnContainer = document.getElementById('sortButtons');
const tooltip = document.getElementById('tooltip'); 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 ------------------------------------------- */ /* ---------- constants ------------------------------------------- */
const MAX_Z = 10; const MAX_Z = 10;
const FOCUS_ZOOM = 3; // zoom level when you click a name const FOCUS_ZOOM = 3; // zoom level when you click a name
@ -19,6 +24,44 @@ const MAP_BOUNDS = {
south: -102.00 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 ---------------------------------- */ /* ---------- sort configuration ---------------------------------- */
const sortOptions = [ const sortOptions = [
{ {
@ -132,8 +175,8 @@ function hideTooltip() {
async function pollLive() { async function pollLive() {
try { try {
const [liveRes, trailsRes] = await Promise.all([ const [liveRes, trailsRes] = await Promise.all([
fetch('/live/'), fetch(`${API_BASE}/live/`),
fetch('/trails/?seconds=600'), fetch(`${API_BASE}/trails/?seconds=600`),
]); ]);
const { players } = await liveRes.json(); const { players } = await liveRes.json();
const { trails } = await trailsRes.json(); const { trails } = await trailsRes.json();
@ -162,6 +205,7 @@ img.onload = () => {
} }
fitToWindow(); fitToWindow();
startPolling(); startPolling();
initWebSocket();
}; };
/* ---------- rendering sorted list & dots ------------------------ */ /* ---------- rendering sorted list & dots ------------------------ */
@ -215,6 +259,15 @@ function render(players) {
li.addEventListener('click', () => selectPlayer(p, x, y)); li.addEventListener('click', () => selectPlayer(p, x, y));
if (p.character_name === selected) li.classList.add('selected'); 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); list.appendChild(li);
}); });
} }
@ -253,6 +306,103 @@ function selectPlayer(p, x, y) {
renderList(); // keep sorted + highlight 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 -------------------------------- */ /* ---------- pan & zoom handlers -------------------------------- */
wrap.addEventListener('wheel', e => { wrap.addEventListener('wheel', e => {
e.preventDefault(); e.preventDefault();

View file

@ -7,6 +7,11 @@
--text: #eee; --text: #eee;
--accent: #88f; --accent: #88f;
} }
/* Placeholder text in chat input should be white */
.chat-input::placeholder {
color: #fff;
opacity: 0.7;
}
html { html {
margin: 0; margin: 0;
@ -201,6 +206,81 @@ body {
background: var(--accent); background: var(--accent);
color: #111; 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.onlinetime::before { content: "🕑 "}
.stat.deaths::before { content: "💀 "} .stat.deaths::before { content: "💀 "}