Compare commits
6 commits
master
...
WS-enabled
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d396942deb | ||
|
|
0313c2a2ae | ||
|
|
b94f064118 | ||
|
|
337eff56aa | ||
|
|
73ae756e5c | ||
|
|
a121d57a13 |
11 changed files with 734 additions and 108 deletions
28
Dockerfile
Normal file
28
Dockerfile
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Dockerfile for Dereth Tracker application
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Upgrade pip and install Python dependencies
|
||||||
|
RUN python -m pip install --upgrade pip && \
|
||||||
|
pip install --no-cache-dir fastapi uvicorn pydantic pandas matplotlib websockets
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY static/ /app/static/
|
||||||
|
COPY main.py /app/main.py
|
||||||
|
COPY db.py /app/db.py
|
||||||
|
COPY Dockerfile /Dockerfile
|
||||||
|
# Expose the application port
|
||||||
|
EXPOSE 8765
|
||||||
|
|
||||||
|
# Default environment variables (override as needed)
|
||||||
|
ENV DB_MAX_SIZE_MB=2048 \
|
||||||
|
DB_RETENTION_DAYS=7 \
|
||||||
|
DB_MAX_SQL_LENGTH=1000000000 \
|
||||||
|
DB_MAX_SQL_VARIABLES=32766 \
|
||||||
|
DB_WAL_AUTOCHECKPOINT_PAGES=1000 \
|
||||||
|
SHARED_SECRET=your_shared_secret
|
||||||
|
|
||||||
|
# Run the FastAPI application with Uvicorn
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765", "--reload", "--workers", "1"]
|
||||||
48
FIXES.md
Normal file
48
FIXES.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Planned Fixes and Enhancements
|
||||||
|
|
||||||
|
_This document captures the next set of improvements and fixes for Dereth Tracker._
|
||||||
|
|
||||||
|
## 1. Chat Window Styling and Format
|
||||||
|
- **Terminal-style chat interface**
|
||||||
|
- Redesign the chat window to mimic 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._
|
||||||
38
LESSONSLEARNED.md
Normal file
38
LESSONSLEARNED.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Lessons Learned
|
||||||
|
|
||||||
|
_This document captures the key takeaways and implementation details from today's troubleshooting session._
|
||||||
|
|
||||||
|
## 1. API Routing & Proxy Configuration
|
||||||
|
- **API_BASE constant**: The frontend (`static/script.js`) uses a base path `API_BASE` (default `/api`) to prefix all HTTP and WebSocket calls. Always update this to match your proxy mount point.
|
||||||
|
- **Nginx WebSocket forwarding**: To proxy WebSockets, you must forward the `Upgrade` and `Connection` headers:
|
||||||
|
```nginx
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
```
|
||||||
|
Without these, the WS handshake downgrades to a normal HTTP GET, resulting in 404s.
|
||||||
|
|
||||||
|
## 2. Debugging WebSocket Traffic
|
||||||
|
- Logged all incoming WS frames in `main.py`:
|
||||||
|
- `[WS-PLUGIN RX] <client>: <raw>` for messages on `/ws/position`
|
||||||
|
- `[WS-LIVE RX] <client>: <parsed-json>` for messages on `/ws/live`
|
||||||
|
- These prints surface registration, telemetry, chat, and command packets, aiding root-cause analysis.
|
||||||
|
|
||||||
|
## 3. Data Serialization Fix
|
||||||
|
- Python `datetime` objects are not JSON-serializable by default. We wrapped outbound payloads via 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.
|
||||||
94
README.md
94
README.md
|
|
@ -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
38
db.py
|
|
@ -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(
|
||||||
|
|
|
||||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
dereth-tracker:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8765:8765"
|
||||||
|
volumes:
|
||||||
|
# Mount local database file for persistence
|
||||||
|
- "./dereth.db:/app/dereth.db"
|
||||||
|
- "./main.py:/app/main.py"
|
||||||
|
- "./db.py:/app/db.py"
|
||||||
|
- "./static:/app/static"
|
||||||
|
environment:
|
||||||
|
# Override database and application settings as needed
|
||||||
|
DB_MAX_SIZE_MB: "2048"
|
||||||
|
DB_RETENTION_DAYS: "7"
|
||||||
|
DB_MAX_SQL_LENGTH: "1000000000"
|
||||||
|
DB_MAX_SQL_VARIABLES: "32766"
|
||||||
|
DB_WAL_AUTOCHECKPOINT_PAGES: "1000"
|
||||||
|
SHARED_SECRET: "your_shared_secret"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
161
main.py
161
main.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -16,7 +17,7 @@ from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
# test
|
||||||
# In-memory store of the last packet per character
|
# In-memory store of the last packet per character
|
||||||
live_snapshots: Dict[str, dict] = {}
|
live_snapshots: Dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
241
static/script.js
241
static/script.js
|
|
@ -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 ------------------------ */
|
||||||
|
|
@ -213,8 +257,29 @@ function render(players) {
|
||||||
<span class="stat deaths">${p.deaths}</span>
|
<span class="stat deaths">${p.deaths}</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Color the metastate pill according to its value
|
||||||
|
const metaSpan = li.querySelector('.stat.meta');
|
||||||
|
if (metaSpan) {
|
||||||
|
const goodStates = ['default', 'default2', 'hunt', 'combat'];
|
||||||
|
const state = (p.vt_state || '').toString().toLowerCase();
|
||||||
|
if (goodStates.includes(state)) {
|
||||||
|
metaSpan.classList.add('green');
|
||||||
|
} else {
|
||||||
|
metaSpan.classList.add('red');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
li.addEventListener('click', () => selectPlayer(p, x, y));
|
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 +318,178 @@ 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 & 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 -------------------------------- */
|
/* ---------- pan & zoom handlers -------------------------------- */
|
||||||
wrap.addEventListener('wheel', e => {
|
wrap.addEventListener('wheel', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
100
static/style.css
100
static/style.css
|
|
@ -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;
|
||||||
|
|
@ -197,10 +202,103 @@ body {
|
||||||
.stat.kills::before { content: "⚔️ "; }
|
.stat.kills::before { content: "⚔️ "; }
|
||||||
.stat.kph::after { content: " KPH"; font-size:0.7em; color:#aaa; }
|
.stat.kph::after { content: " KPH"; font-size:0.7em; color:#aaa; }
|
||||||
.stat.rares::after { content: " Rares"; font-size:0.7em; color:#aaa; }
|
.stat.rares::after { content: " Rares"; font-size:0.7em; color:#aaa; }
|
||||||
.stat.meta {
|
/* metastate pill colors are assigned dynamically: green for “good” states, red otherwise */
|
||||||
|
#playerList li .stat.meta {
|
||||||
|
/* fallback */
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #111;
|
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.onlinetime::before { content: "🕑 "}
|
||||||
.stat.deaths::before { content: "💀 "}
|
.stat.deaths::before { content: "💀 "}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue