Compare commits
No commits in common. "WS-enabled" and "master" have entirely different histories.
WS-enabled
...
master
11 changed files with 108 additions and 734 deletions
28
Dockerfile
28
Dockerfile
|
|
@ -1,28 +0,0 @@
|
|||
# Dockerfile for Dereth Tracker application
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade pip and install Python dependencies
|
||||
RUN python -m pip install --upgrade pip && \
|
||||
pip install --no-cache-dir fastapi uvicorn pydantic pandas matplotlib websockets
|
||||
|
||||
# Copy application code
|
||||
COPY static/ /app/static/
|
||||
COPY main.py /app/main.py
|
||||
COPY db.py /app/db.py
|
||||
COPY Dockerfile /Dockerfile
|
||||
# Expose the application port
|
||||
EXPOSE 8765
|
||||
|
||||
# Default environment variables (override as needed)
|
||||
ENV DB_MAX_SIZE_MB=2048 \
|
||||
DB_RETENTION_DAYS=7 \
|
||||
DB_MAX_SQL_LENGTH=1000000000 \
|
||||
DB_MAX_SQL_VARIABLES=32766 \
|
||||
DB_WAL_AUTOCHECKPOINT_PAGES=1000 \
|
||||
SHARED_SECRET=your_shared_secret
|
||||
|
||||
# Run the FastAPI application with Uvicorn
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765", "--reload", "--workers", "1"]
|
||||
48
FIXES.md
48
FIXES.md
|
|
@ -1,48 +0,0 @@
|
|||
# Planned Fixes and Enhancements
|
||||
|
||||
_This document captures the next set of improvements and fixes for Dereth Tracker._
|
||||
|
||||
## 1. Chat Window Styling and Format
|
||||
- **Terminal-style chat interface**
|
||||
- Redesign the chat window to mimic Asheron’s Call in-game chat: monospaced font, dark semi-transparent background, and text entry at the bottom.
|
||||
- Implement timestamped message prefixes (e.g., `[12:34] character: message`).
|
||||
- Support command- and system-level styling (e.g., whispers, party chat) with distinct color cues.
|
||||
|
||||
## 2. Incoming Message Parsing
|
||||
- **Strip protocol overhead**
|
||||
- Remove JSON envelope artifacts (e.g., remove quotes, braces) so only raw message text appears.
|
||||
- Validate and sanitize incoming payloads (e.g., escape HTML, truncate length).
|
||||
- Optionally support rich-text / emotes by parsing simple markup (e.g., `*bold*`, `/me action`).
|
||||
|
||||
## 3. Message Color Scheme
|
||||
- **Per-character consistent colors**
|
||||
- Map each character name to a unique, but legible, pastel or muted color.
|
||||
- Ensure sufficient contrast with the chat background (WCAG AA compliance).
|
||||
- Provide user override settings for theme (light/dark) and custom palettes.
|
||||
|
||||
## 4. Command Prompt Integration
|
||||
- **Client-side command entry**
|
||||
- Allow slash-commands in chat input (e.g., `/kick PlayerName`, `/whisper PlayerName Hello`).
|
||||
- Validate commands before sending to `/ws/live` and route to the correct plugin WebSocket.
|
||||
- Show feedback on command success/failure in the chat window.
|
||||
|
||||
## 5. Security Hardening
|
||||
- **Authentication & Authorization**
|
||||
- Enforce TLS (HTTPS/WSS) for all HTTP and WebSocket connections.
|
||||
- Protect `/ws/position` with rotating shared secrets or token-based auth (e.g., JWT).
|
||||
- Rate-limit incoming telemetry and chat messages to prevent flooding.
|
||||
- Sanitize all inputs to guard against injection (SQL, XSS) and implement strict CSP headers.
|
||||
|
||||
## 6. Performance and Scalability
|
||||
- **Throttling and Load Handling**
|
||||
- Batch updates during high-frequency telemetry bursts to reduce WebSocket churn.
|
||||
- Cache recent `/live` and `/trails` responses in-memory to relieve SQLite under load.
|
||||
- Plan for horizontal scaling: stateless FastAPI behind a load balancer with shared database or in-memory pub/sub.
|
||||
|
||||
## 7. Testing and Quality Assurance
|
||||
- **Automated Tests**
|
||||
- Unit tests for `db.save_snapshot`, HTTP endpoints, and WebSocket handlers.
|
||||
- E2E tests for the frontend UI (using Puppeteer or Playwright) to verify chat and map functionality.
|
||||
- Security regression tests for input sanitization and auth enforcement.
|
||||
|
||||
_Refer to this list when planning next development sprints. Each item should be broken down into individual tickets or pull requests._
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
# Lessons Learned
|
||||
|
||||
_This document captures the key takeaways and implementation details from today's troubleshooting session._
|
||||
|
||||
## 1. API Routing & Proxy Configuration
|
||||
- **API_BASE constant**: The frontend (`static/script.js`) uses a base path `API_BASE` (default `/api`) to prefix all HTTP and WebSocket calls. Always update this to match your proxy mount point.
|
||||
- **Nginx WebSocket forwarding**: To proxy WebSockets, you must forward the `Upgrade` and `Connection` headers:
|
||||
```nginx
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
```
|
||||
Without these, the WS handshake downgrades to a normal HTTP GET, resulting in 404s.
|
||||
|
||||
## 2. Debugging WebSocket Traffic
|
||||
- Logged all incoming WS frames in `main.py`:
|
||||
- `[WS-PLUGIN RX] <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 is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a SQLite database, and provides a live map interface along with a sample data generator for testing.
|
||||
Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a SQLite database, and provides both a live map interface and an analytics dashboard.
|
||||
|
||||
## Table of Contents
|
||||
- [Overview](#overview)
|
||||
|
|
@ -12,6 +12,7 @@ Dereth Tracker is a real-time telemetry service for the world of Dereth. It coll
|
|||
- [API Reference](#api-reference)
|
||||
- [Frontend](#frontend)
|
||||
- [Database Schema](#database-schema)
|
||||
- [Sample Payload](#sample-payload)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
## Overview
|
||||
|
|
@ -20,16 +21,16 @@ This project provides:
|
|||
- A FastAPI backend with endpoints for receiving and querying telemetry data.
|
||||
- SQLite-based storage for snapshots and live state.
|
||||
- A live, interactive map using static HTML, CSS, and JavaScript.
|
||||
- A sample data generator script (`generate_data.py`) for simulating telemetry snapshots.
|
||||
- An analytics dashboard for visualizing kills and session metrics.
|
||||
|
||||
## Features
|
||||
|
||||
- **WebSocket /ws/position**: Stream telemetry snapshots (protected by a shared secret).
|
||||
- **POST /position**: Submit a telemetry snapshot (protected by a shared secret).
|
||||
- **GET /live**: Fetch active players seen in the last 30 seconds.
|
||||
- **GET /history**: Retrieve historical telemetry data with optional time filtering.
|
||||
- **GET /debug**: Health check endpoint.
|
||||
- **Live Map**: Interactive map interface with panning, zooming, and sorting.
|
||||
- **Sample Data Generator**: `generate_data.py` sends telemetry snapshots over WebSocket for testing.
|
||||
- **Analytics Dashboard**: Interactive charts for kills over time and kills per hour using D3.js.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
|
@ -44,7 +45,6 @@ Python packages:
|
|||
- pydantic
|
||||
- pandas
|
||||
- matplotlib
|
||||
- websockets # required for sample data generator
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -60,14 +60,13 @@ Python packages:
|
|||
```
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
pip install fastapi uvicorn pydantic pandas matplotlib websockets
|
||||
pip install fastapi uvicorn pydantic pandas matplotlib
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- Update the `SHARED_SECRET` in `main.py` to match your plugin (default: `"your_shared_secret"`).
|
||||
- The SQLite database file `dereth.db` is created in the project root. To change the path, edit `DB_FILE` in `db.py`.
|
||||
- To limit the maximum database size, set the environment variable `DB_MAX_SIZE_MB` (default: 2048 MB).
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
@ -77,66 +76,17 @@ Start the server using Uvicorn:
|
|||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## NGINX Proxy Configuration
|
||||
|
||||
If you cannot reassign the existing `/live` and `/trails` routes, you can namespace this service under `/api` (or any other prefix) and configure NGINX accordingly. Be sure to forward WebSocket upgrade headers so that `/ws/live` and `/ws/position` continue to work. Example:
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8765/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# WebSocket support
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
```
|
||||
Then the browser client (static/script.js) will fetch `/api/live/` and `/api/trails/` to reach this new server.
|
||||
|
||||
- Live Map: `http://localhost:8000/` (or `http://<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; }`.
|
||||
- Live Map: `http://localhost:8000/`
|
||||
- Analytics Dashboard: `http://localhost:8000/graphs.html`
|
||||
|
||||
## API Reference
|
||||
|
||||
### WebSocket /ws/position
|
||||
Stream telemetry snapshots over a WebSocket connection. Provide your shared secret either as a query parameter or WebSocket header:
|
||||
|
||||
```
|
||||
ws://<host>:<port>/ws/position?secret=<shared_secret>
|
||||
```
|
||||
or
|
||||
```
|
||||
X-Plugin-Secret: <shared_secret>
|
||||
```
|
||||
|
||||
After connecting, send JSON messages matching the `TelemetrySnapshot` schema. For example:
|
||||
### POST /position
|
||||
Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: <shared_secret>`.
|
||||
|
||||
**Request Body Example:**
|
||||
```json
|
||||
{
|
||||
"type": "telemetry",
|
||||
"character_name": "Dunking Rares",
|
||||
"char_tag": "moss",
|
||||
"session_id": "dunk-20250422-xyz",
|
||||
|
|
@ -154,23 +104,6 @@ After connecting, send JSON messages matching the `TelemetrySnapshot` schema. Fo
|
|||
}
|
||||
```
|
||||
|
||||
### Chat messages
|
||||
You can also send chat envelopes over the same WebSocket to display messages in the browser. Fields:
|
||||
- `type`: must be "chat"
|
||||
- `character_name`: target player name
|
||||
- `text`: message content
|
||||
- `color` (optional): CSS color string (e.g. "#ff8800"); if sent as an integer (0xRRGGBB), it will be converted to hex.
|
||||
|
||||
Example chat payload:
|
||||
```json
|
||||
{
|
||||
"type": "chat",
|
||||
"character_name": "MyCharacter",
|
||||
"text": "Hello world!",
|
||||
"color": "#88f"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /live
|
||||
Returns active players seen within the last 30 seconds:
|
||||
|
||||
|
|
@ -198,12 +131,17 @@ Response:
|
|||
## Frontend
|
||||
|
||||
- **Live Map**: `static/index.html` – Real-time player positions on a map.
|
||||
- **Analytics Dashboard**: `static/graphs.html` – Interactive charts powered by [D3.js](https://d3js.org/).
|
||||
|
||||
## Database Schema
|
||||
|
||||
- **telemetry_log**: Stored history of snapshots.
|
||||
- **live_state**: Current snapshot per character (upserted).
|
||||
|
||||
## Sample Payload
|
||||
|
||||
See `test.json` for an example telemetry snapshot.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Feel free to open issues or submit pull requests.
|
||||
|
|
|
|||
38
db.py
38
db.py
|
|
@ -1,38 +1,13 @@
|
|||
import os
|
||||
import sqlite3
|
||||
from typing import Dict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DB_FILE = "dereth.db"
|
||||
# Maximum allowed database size (in MB). Defaults to 2048 (2GB). Override via env DB_MAX_SIZE_MB.
|
||||
MAX_DB_SIZE_MB = int(os.getenv("DB_MAX_SIZE_MB", "2048"))
|
||||
# Retention window for telemetry history in days. Override via env DB_RETENTION_DAYS.
|
||||
MAX_RETENTION_DAYS = int(os.getenv("DB_RETENTION_DAYS", "7"))
|
||||
# SQLite runtime limits customization
|
||||
DB_MAX_SQL_LENGTH = int(os.getenv("DB_MAX_SQL_LENGTH", "1000000000"))
|
||||
DB_MAX_SQL_VARIABLES = int(os.getenv("DB_MAX_SQL_VARIABLES", "32766"))
|
||||
# Number of WAL frames to write before forcing a checkpoint (override via env DB_WAL_AUTOCHECKPOINT_PAGES)
|
||||
DB_WAL_AUTOCHECKPOINT_PAGES = int(os.getenv("DB_WAL_AUTOCHECKPOINT_PAGES", "1000"))
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Create tables if they do not exist (extended with kills_per_hour and onlinetime)."""
|
||||
# Open connection with a longer timeout
|
||||
conn = sqlite3.connect(DB_FILE, timeout=30)
|
||||
# Bump SQLite runtime limits
|
||||
conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, DB_MAX_SQL_LENGTH)
|
||||
conn.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, DB_MAX_SQL_LENGTH)
|
||||
conn.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, DB_MAX_SQL_VARIABLES)
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
c = conn.cursor()
|
||||
# Enable auto_vacuum FULL and rebuild DB so that deletions shrink the file
|
||||
c.execute("PRAGMA auto_vacuum=FULL;")
|
||||
conn.commit()
|
||||
c.execute("VACUUM;")
|
||||
conn.commit()
|
||||
# Switch to WAL mode for concurrency, adjust checkpointing, and enforce max size
|
||||
c.execute("PRAGMA journal_mode=WAL")
|
||||
c.execute("PRAGMA synchronous=NORMAL")
|
||||
c.execute(f"PRAGMA wal_autocheckpoint={DB_WAL_AUTOCHECKPOINT_PAGES}")
|
||||
|
||||
# History log
|
||||
c.execute(
|
||||
|
|
@ -85,17 +60,8 @@ def init_db() -> None:
|
|||
|
||||
def save_snapshot(data: Dict) -> None:
|
||||
"""Insert snapshot into history and upsert into live_state (with new fields)."""
|
||||
# Open connection with a longer busy timeout
|
||||
conn = sqlite3.connect(DB_FILE, timeout=30)
|
||||
# Bump SQLite runtime limits on this connection
|
||||
conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, DB_MAX_SQL_LENGTH)
|
||||
conn.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, DB_MAX_SQL_LENGTH)
|
||||
conn.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, DB_MAX_SQL_VARIABLES)
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
c = conn.cursor()
|
||||
# Ensure WAL mode, checkpointing, and size limit on this connection
|
||||
c.execute("PRAGMA journal_mode=WAL")
|
||||
c.execute("PRAGMA synchronous=NORMAL")
|
||||
c.execute(f"PRAGMA wal_autocheckpoint={DB_WAL_AUTOCHECKPOINT_PAGES}")
|
||||
|
||||
# Insert full history row
|
||||
c.execute(
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
dereth-tracker:
|
||||
build: .
|
||||
ports:
|
||||
- "127.0.0.1:8765:8765"
|
||||
volumes:
|
||||
# Mount local database file for persistence
|
||||
- "./dereth.db:/app/dereth.db"
|
||||
- "./main.py:/app/main.py"
|
||||
- "./db.py:/app/db.py"
|
||||
- "./static:/app/static"
|
||||
environment:
|
||||
# Override database and application settings as needed
|
||||
DB_MAX_SIZE_MB: "2048"
|
||||
DB_RETENTION_DAYS: "7"
|
||||
DB_MAX_SQL_LENGTH: "1000000000"
|
||||
DB_MAX_SQL_VARIABLES: "32766"
|
||||
DB_WAL_AUTOCHECKPOINT_PAGES: "1000"
|
||||
SHARED_SECRET: "your_shared_secret"
|
||||
restart: unless-stopped
|
||||
|
|
@ -1,45 +1,45 @@
|
|||
import asyncio
|
||||
import websockets
|
||||
import json
|
||||
import httpx
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from time import sleep
|
||||
from main import TelemetrySnapshot
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
def main() -> None:
|
||||
wait = 10
|
||||
online_time = 24 * 3600 # start at 1 day
|
||||
ew = 0.0
|
||||
ns = 0.0
|
||||
uri = "ws://localhost:8000/ws/position?secret=your_shared_secret"
|
||||
async with websockets.connect(uri) as websocket:
|
||||
print(f"Connected to {uri}")
|
||||
while True:
|
||||
snapshot = TelemetrySnapshot(
|
||||
character_name="Test name",
|
||||
char_tag="test_tag",
|
||||
session_id="test_session_id",
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
ew=ew,
|
||||
ns=ns,
|
||||
z=0,
|
||||
kills=0,
|
||||
kills_per_hour="kph_str",
|
||||
onlinetime=str(timedelta(seconds=online_time)),
|
||||
deaths=0,
|
||||
rares_found=0,
|
||||
prismatic_taper_count=0,
|
||||
vt_state="test state",
|
||||
)
|
||||
# wrap in envelope with message type
|
||||
payload = snapshot.model_dump()
|
||||
payload["type"] = "telemetry"
|
||||
await websocket.send(json.dumps(payload, default=str))
|
||||
print(f"Sent snapshot: EW={ew:.2f}, NS={ns:.2f}")
|
||||
await asyncio.sleep(wait)
|
||||
ew += 0.1
|
||||
ns += 0.1
|
||||
online_time += wait
|
||||
ew = 0
|
||||
ns = 0
|
||||
while True:
|
||||
snapshot = TelemetrySnapshot(
|
||||
character_name="Test name",
|
||||
char_tag="test_tag",
|
||||
session_id="test_session_id",
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
ew=ew,
|
||||
ns=ns,
|
||||
z=0,
|
||||
kills=0,
|
||||
kills_per_hour="kph_str",
|
||||
onlinetime=str(timedelta(seconds=online_time)),
|
||||
deaths=0,
|
||||
rares_found=0,
|
||||
prismatic_taper_count=0,
|
||||
vt_state="test state",
|
||||
)
|
||||
resp = httpx.post(
|
||||
"http://localhost:8000/position/",
|
||||
data=snapshot.model_dump_json(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-PLUGIN-SECRET": "your_shared_secret",
|
||||
},
|
||||
)
|
||||
print(resp)
|
||||
sleep(wait)
|
||||
ew += 0.1
|
||||
ns += 0.1
|
||||
online_time += wait
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
main()
|
||||
|
|
|
|||
161
main.py
161
main.py
|
|
@ -7,7 +7,6 @@ from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketD
|
|||
from fastapi.responses import JSONResponse
|
||||
from fastapi.routing import APIRoute
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -17,7 +16,7 @@ from starlette.concurrency import run_in_threadpool
|
|||
|
||||
# ------------------------------------------------------------------
|
||||
app = FastAPI()
|
||||
# test
|
||||
|
||||
# In-memory store of the last packet per character
|
||||
live_snapshots: Dict[str, dict] = {}
|
||||
|
||||
|
|
@ -51,6 +50,31 @@ def on_startup():
|
|||
init_db()
|
||||
|
||||
|
||||
# ------------------------ POST ----------------------------------
|
||||
@app.post("/position")
|
||||
@app.post("/position/")
|
||||
async def receive_snapshot(
|
||||
snapshot: TelemetrySnapshot, x_plugin_secret: str = Header(None)
|
||||
):
|
||||
if x_plugin_secret != SHARED_SECRET:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# cache for /live
|
||||
live_snapshots[snapshot.character_name] = snapshot.dict()
|
||||
|
||||
# save in sqlite
|
||||
save_snapshot(snapshot.dict())
|
||||
|
||||
# optional log-file append
|
||||
# with open(LOG_FILE, "a") as f:
|
||||
# f.write(json.dumps(snapshot.dict(), default=str) + "\n")
|
||||
|
||||
print(
|
||||
f"[{datetime.now()}] {snapshot.character_name} @ NS={snapshot.ns:+.2f}, EW={snapshot.ew:+.2f}"
|
||||
)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ------------------------ GET -----------------------------------
|
||||
@app.get("/debug")
|
||||
|
|
@ -58,33 +82,22 @@ def debug():
|
|||
return {"status": "OK"}
|
||||
|
||||
|
||||
@app.get("/live", response_model=dict)
|
||||
@app.get("/live/", response_model=dict)
|
||||
@app.get("/live")
|
||||
@app.get("/live/")
|
||||
def get_live_players():
|
||||
# compute cutoff once
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
cutoff = now_utc - ACTIVE_WINDOW
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute("SELECT * FROM live_state").fetchall()
|
||||
conn.close()
|
||||
|
||||
cutoff_sql = cutoff.strftime("%Y-%m-%d %H:%M:%S")
|
||||
# aware cutoff (UTC)
|
||||
cutoff = datetime.utcnow().replace(tzinfo=timezone.utc) - ACTIVE_WINDOW
|
||||
|
||||
try:
|
||||
with sqlite3.connect(DB_FILE) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
query = """
|
||||
SELECT *
|
||||
FROM live_state
|
||||
WHERE datetime(timestamp) > datetime(?, 'utc')
|
||||
"""
|
||||
rows = conn.execute(query, (cutoff_sql,)).fetchall()
|
||||
|
||||
except sqlite3.Error as e:
|
||||
# log e if you have logging set up
|
||||
raise HTTPException(status_code=500, detail="Database error")
|
||||
|
||||
# build list of dicts
|
||||
players = []
|
||||
for r in rows:
|
||||
players.append(dict(r))
|
||||
players = [
|
||||
dict(r)
|
||||
for r in rows
|
||||
if datetime.fromisoformat(r["timestamp"].replace("Z", "+00:00")) > cutoff
|
||||
]
|
||||
|
||||
return JSONResponse(content={"players": players})
|
||||
|
||||
|
|
@ -185,114 +198,42 @@ def get_trails(
|
|||
|
||||
# -------------------- WebSocket endpoints -----------------------
|
||||
browser_conns: set[WebSocket] = set()
|
||||
# Map of registered plugin clients: character_name -> WebSocket
|
||||
plugin_conns: Dict[str, WebSocket] = {}
|
||||
|
||||
async def _broadcast_to_browser_clients(snapshot: dict):
|
||||
# Ensure all data (e.g. datetime) is JSON-serializable
|
||||
data = jsonable_encoder(snapshot)
|
||||
for ws in list(browser_conns):
|
||||
try:
|
||||
await ws.send_json(data)
|
||||
await ws.send_json(snapshot)
|
||||
except WebSocketDisconnect:
|
||||
browser_conns.remove(ws)
|
||||
|
||||
@app.websocket("/ws/position")
|
||||
async def ws_receive_snapshots(
|
||||
websocket: WebSocket,
|
||||
secret: str | None = Query(None),
|
||||
x_plugin_secret: str | None = Header(None)
|
||||
):
|
||||
# Verify shared secret from query parameter or header
|
||||
key = secret or x_plugin_secret
|
||||
if key != SHARED_SECRET:
|
||||
# Reject without completing the WebSocket handshake
|
||||
async def ws_receive_snapshots(websocket: WebSocket, secret: str = Query(...)):
|
||||
await websocket.accept()
|
||||
if secret != SHARED_SECRET:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
# Accept the WebSocket connection
|
||||
await websocket.accept()
|
||||
print(f"[WS] Plugin connected: {websocket.client}")
|
||||
try:
|
||||
while True:
|
||||
# Read next text frame
|
||||
try:
|
||||
raw = await websocket.receive_text()
|
||||
# Debug: log all incoming plugin WebSocket messages
|
||||
print(f"[WS-PLUGIN RX] {websocket.client}: {raw}")
|
||||
except WebSocketDisconnect:
|
||||
print(f"[WS] Plugin disconnected: {websocket.client}")
|
||||
break
|
||||
# Parse JSON payload
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
msg_type = data.get("type")
|
||||
# Registration message: map character to this socket
|
||||
if msg_type == "register":
|
||||
name = data.get("character_name") or data.get("player_name")
|
||||
if isinstance(name, str):
|
||||
plugin_conns[name] = websocket
|
||||
continue
|
||||
# Telemetry message: save to DB and broadcast
|
||||
if msg_type == "telemetry":
|
||||
payload = data.copy()
|
||||
payload.pop("type", None)
|
||||
snap = TelemetrySnapshot.parse_obj(payload)
|
||||
live_snapshots[snap.character_name] = snap.dict()
|
||||
await run_in_threadpool(save_snapshot, snap.dict())
|
||||
await _broadcast_to_browser_clients(snap.dict())
|
||||
continue
|
||||
# Chat message: broadcast to browser clients only (no DB write)
|
||||
if msg_type == "chat":
|
||||
await _broadcast_to_browser_clients(data)
|
||||
continue
|
||||
# Unknown message types are ignored
|
||||
finally:
|
||||
# Clean up any plugin registrations for this socket
|
||||
to_remove = [n for n, ws in plugin_conns.items() if ws is websocket]
|
||||
for n in to_remove:
|
||||
del plugin_conns[n]
|
||||
print(f"[WS] Cleaned up plugin connections for {websocket.client}")
|
||||
data = await websocket.receive_json()
|
||||
snap = TelemetrySnapshot.parse_obj(data)
|
||||
live_snapshots[snap.character_name] = snap.dict()
|
||||
await run_in_threadpool(save_snapshot, snap.dict())
|
||||
await _broadcast_to_browser_clients(snap.dict())
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
|
||||
@app.websocket("/ws/live")
|
||||
async def ws_live_updates(websocket: WebSocket):
|
||||
# Browser clients connect here to receive telemetry and chat, and send commands
|
||||
await websocket.accept()
|
||||
browser_conns.add(websocket)
|
||||
try:
|
||||
while True:
|
||||
# Receive command messages from browser
|
||||
try:
|
||||
data = await websocket.receive_json()
|
||||
# Debug: log all incoming browser WebSocket messages
|
||||
print(f"[WS-LIVE RX] {websocket.client}: {data}")
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
# Determine command envelope format (new or legacy)
|
||||
if "player_name" in data and "command" in data:
|
||||
# New format: { player_name, command }
|
||||
target_name = data["player_name"]
|
||||
payload = data
|
||||
elif data.get("type") == "command" and "character_name" in data and "text" in data:
|
||||
# Legacy format: { type: 'command', character_name, text }
|
||||
target_name = data.get("character_name")
|
||||
payload = {"player_name": target_name, "command": data.get("text")}
|
||||
else:
|
||||
# Not a recognized command envelope
|
||||
continue
|
||||
# Forward command envelope to the appropriate plugin WebSocket
|
||||
target_ws = plugin_conns.get(target_name)
|
||||
if target_ws:
|
||||
await target_ws.send_json(payload)
|
||||
await asyncio.sleep(3600)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
browser_conns.remove(websocket)
|
||||
|
||||
|
||||
# -------------------- static frontend ---------------------------
|
||||
# static frontend
|
||||
app.mount("/", StaticFiles(directory="static", html=True), name="static")
|
||||
|
||||
# list routes for convenience
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB |
241
static/script.js
241
static/script.js
|
|
@ -8,11 +8,6 @@ const list = document.getElementById('playerList');
|
|||
const btnContainer = document.getElementById('sortButtons');
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
|
||||
// WebSocket for chat and commands
|
||||
let socket;
|
||||
// Keep track of open chat windows: character_name -> DOM element
|
||||
const chatWindows = {};
|
||||
|
||||
/* ---------- constants ------------------------------------------- */
|
||||
const MAX_Z = 10;
|
||||
const FOCUS_ZOOM = 3; // zoom level when you click a name
|
||||
|
|
@ -24,44 +19,6 @@ const MAP_BOUNDS = {
|
|||
south: -102.00
|
||||
};
|
||||
|
||||
// Base path for tracker API endpoints; prefix API calls with '/api' when served behind a proxy
|
||||
const API_BASE = '/api';
|
||||
// Maximum number of lines to retain in each chat window scrollback
|
||||
const MAX_CHAT_LINES = 1000;
|
||||
// Map numeric chat color codes to CSS hex colors
|
||||
const CHAT_COLOR_MAP = {
|
||||
0: '#00FF00', // Broadcast
|
||||
2: '#FFFFFF', // Speech
|
||||
3: '#FFD700', // Tell
|
||||
4: '#CCCC00', // OutgoingTell
|
||||
5: '#FF00FF', // System
|
||||
6: '#FF0000', // Combat
|
||||
7: '#00CCFF', // Magic
|
||||
8: '#DDDDDD', // Channel
|
||||
9: '#FF9999', // ChannelSend
|
||||
10: '#FFFF33', // Social
|
||||
11: '#CCFF33', // SocialSend
|
||||
12: '#FFFFFF', // Emote
|
||||
13: '#00FFFF', // Advancement
|
||||
14: '#66CCFF', // Abuse
|
||||
15: '#FF0000', // Help
|
||||
16: '#33FF00', // Appraisal
|
||||
17: '#0099FF', // Spellcasting
|
||||
18: '#FF6600', // Allegiance
|
||||
19: '#CC66FF', // Fellowship
|
||||
20: '#00FF00', // WorldBroadcast
|
||||
21: '#FF0000', // CombatEnemy
|
||||
22: '#FF33CC', // CombatSelf
|
||||
23: '#00CC00', // Recall
|
||||
24: '#00FF00', // Craft
|
||||
25: '#00FF66', // Salvaging
|
||||
27: '#FFFFFF', // General
|
||||
28: '#33FF33', // Trade
|
||||
29: '#CCCCCC', // LFG
|
||||
30: '#CC00CC', // Roleplay
|
||||
31: '#FFFF00' // AdminTell
|
||||
};
|
||||
|
||||
/* ---------- sort configuration ---------------------------------- */
|
||||
const sortOptions = [
|
||||
{
|
||||
|
|
@ -175,8 +132,8 @@ function hideTooltip() {
|
|||
async function pollLive() {
|
||||
try {
|
||||
const [liveRes, trailsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/live/`),
|
||||
fetch(`${API_BASE}/trails/?seconds=600`),
|
||||
fetch('/live/'),
|
||||
fetch('/trails/?seconds=600'),
|
||||
]);
|
||||
const { players } = await liveRes.json();
|
||||
const { trails } = await trailsRes.json();
|
||||
|
|
@ -205,7 +162,6 @@ img.onload = () => {
|
|||
}
|
||||
fitToWindow();
|
||||
startPolling();
|
||||
initWebSocket();
|
||||
};
|
||||
|
||||
/* ---------- rendering sorted list & dots ------------------------ */
|
||||
|
|
@ -257,29 +213,8 @@ function render(players) {
|
|||
<span class="stat deaths">${p.deaths}</span>
|
||||
`;
|
||||
|
||||
// Color the metastate pill according to its value
|
||||
const metaSpan = li.querySelector('.stat.meta');
|
||||
if (metaSpan) {
|
||||
const goodStates = ['default', 'default2', 'hunt', 'combat'];
|
||||
const state = (p.vt_state || '').toString().toLowerCase();
|
||||
if (goodStates.includes(state)) {
|
||||
metaSpan.classList.add('green');
|
||||
} else {
|
||||
metaSpan.classList.add('red');
|
||||
}
|
||||
}
|
||||
|
||||
li.addEventListener('click', () => selectPlayer(p, x, y));
|
||||
if (p.character_name === selected) li.classList.add('selected');
|
||||
// Chat button
|
||||
const chatBtn = document.createElement('button');
|
||||
chatBtn.className = 'chat-btn';
|
||||
chatBtn.textContent = 'Chat';
|
||||
chatBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
showChatWindow(p.character_name);
|
||||
});
|
||||
li.appendChild(chatBtn);
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
|
@ -318,178 +253,6 @@ function selectPlayer(p, x, y) {
|
|||
renderList(); // keep sorted + highlight
|
||||
}
|
||||
|
||||
/* ---------- chat & command handlers ---------------------------- */
|
||||
// Initialize WebSocket for chat and commands
|
||||
function initWebSocket() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${location.host}${API_BASE}/ws/live`;
|
||||
socket = new WebSocket(wsUrl);
|
||||
socket.addEventListener('message', evt => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(evt.data); } catch { return; }
|
||||
if (msg.type === 'chat') {
|
||||
appendChatMessage(msg);
|
||||
}
|
||||
});
|
||||
socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
|
||||
socket.addEventListener('error', e => console.error('WebSocket error:', e));
|
||||
}
|
||||
|
||||
// Display or create a chat window for a character
|
||||
function showChatWindow(name) {
|
||||
if (chatWindows[name]) {
|
||||
// Restore flex layout when reopening & bring to front
|
||||
const existing = chatWindows[name];
|
||||
existing.style.display = 'flex';
|
||||
if (!window.__chatZ) window.__chatZ = 10000;
|
||||
window.__chatZ += 1;
|
||||
existing.style.zIndex = window.__chatZ;
|
||||
return;
|
||||
}
|
||||
const win = document.createElement('div');
|
||||
win.className = 'chat-window';
|
||||
win.dataset.character = name;
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'chat-header';
|
||||
const title = document.createElement('span');
|
||||
title.textContent = `Chat: ${name}`;
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'chat-close-btn';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.addEventListener('click', () => { win.style.display = 'none'; });
|
||||
header.appendChild(title);
|
||||
header.appendChild(closeBtn);
|
||||
win.appendChild(header);
|
||||
// Messages container
|
||||
const msgs = document.createElement('div');
|
||||
msgs.className = 'chat-messages';
|
||||
win.appendChild(msgs);
|
||||
// Input form
|
||||
const form = document.createElement('form');
|
||||
form.className = 'chat-form';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'chat-input';
|
||||
input.placeholder = 'Enter chat...';
|
||||
form.appendChild(input);
|
||||
form.addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
// Send command envelope: player_name and command only
|
||||
socket.send(JSON.stringify({ player_name: name, command: text }));
|
||||
input.value = '';
|
||||
});
|
||||
win.appendChild(form);
|
||||
document.body.appendChild(win);
|
||||
chatWindows[name] = win;
|
||||
|
||||
/* --------------------------------------------------------- */
|
||||
/* enable dragging of the chat window via its header element */
|
||||
/* --------------------------------------------------------- */
|
||||
|
||||
// keep a static counter so newer windows can be brought to front
|
||||
if (!window.__chatZ) window.__chatZ = 10000;
|
||||
|
||||
let drag = false;
|
||||
let startX = 0, startY = 0;
|
||||
let startLeft = 0, startTop = 0;
|
||||
|
||||
header.style.cursor = 'move';
|
||||
|
||||
// bring to front when interacting
|
||||
const bringToFront = () => {
|
||||
window.__chatZ += 1;
|
||||
win.style.zIndex = window.__chatZ;
|
||||
};
|
||||
|
||||
header.addEventListener('mousedown', e => {
|
||||
// don't initiate drag when pressing the close button (or other clickable controls)
|
||||
if (e.target.closest('button')) return;
|
||||
e.preventDefault();
|
||||
drag = true;
|
||||
bringToFront();
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
// current absolute position
|
||||
startLeft = win.offsetLeft;
|
||||
startTop = win.offsetTop;
|
||||
document.body.classList.add('noselect');
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', e => {
|
||||
if (!drag) return;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
win.style.left = `${startLeft + dx}px`;
|
||||
win.style.top = `${startTop + dy}px`;
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', () => {
|
||||
drag = false;
|
||||
document.body.classList.remove('noselect');
|
||||
});
|
||||
|
||||
/* touch support */
|
||||
header.addEventListener('touchstart', e => {
|
||||
if (e.touches.length !== 1 || e.target.closest('button')) return;
|
||||
drag = true;
|
||||
bringToFront();
|
||||
const t = e.touches[0];
|
||||
startX = t.clientX;
|
||||
startY = t.clientY;
|
||||
startLeft = win.offsetLeft;
|
||||
startTop = win.offsetTop;
|
||||
});
|
||||
|
||||
window.addEventListener('touchmove', e => {
|
||||
if (!drag || e.touches.length !== 1) return;
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - startX;
|
||||
const dy = t.clientY - startY;
|
||||
win.style.left = `${startLeft + dx}px`;
|
||||
win.style.top = `${startTop + dy}px`;
|
||||
});
|
||||
|
||||
window.addEventListener('touchend', () => {
|
||||
drag = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Append a chat message to the correct window
|
||||
/**
|
||||
* Append a chat message to the correct window, optionally coloring the text.
|
||||
* msg: { type: 'chat', character_name, text, color? }
|
||||
*/
|
||||
function appendChatMessage(msg) {
|
||||
const { character_name: name, text, color } = msg;
|
||||
const win = chatWindows[name];
|
||||
if (!win) return;
|
||||
const msgs = win.querySelector('.chat-messages');
|
||||
const p = document.createElement('div');
|
||||
if (color !== undefined) {
|
||||
let c = color;
|
||||
if (typeof c === 'number') {
|
||||
// map numeric chat code to configured color, or fallback to raw hex
|
||||
if (CHAT_COLOR_MAP.hasOwnProperty(c)) {
|
||||
c = CHAT_COLOR_MAP[c];
|
||||
} else {
|
||||
c = '#' + c.toString(16).padStart(6, '0');
|
||||
}
|
||||
}
|
||||
p.style.color = c;
|
||||
}
|
||||
p.textContent = text;
|
||||
msgs.appendChild(p);
|
||||
// Enforce max number of lines in scrollback
|
||||
while (msgs.children.length > MAX_CHAT_LINES) {
|
||||
msgs.removeChild(msgs.firstChild);
|
||||
}
|
||||
// Scroll to bottom
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
|
||||
/* ---------- pan & zoom handlers -------------------------------- */
|
||||
wrap.addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
100
static/style.css
100
static/style.css
|
|
@ -7,11 +7,6 @@
|
|||
--text: #eee;
|
||||
--accent: #88f;
|
||||
}
|
||||
/* Placeholder text in chat input should be white */
|
||||
.chat-input::placeholder {
|
||||
color: #fff;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
html {
|
||||
margin: 0;
|
||||
|
|
@ -202,103 +197,10 @@ body {
|
|||
.stat.kills::before { content: "⚔️ "; }
|
||||
.stat.kph::after { content: " KPH"; font-size:0.7em; color:#aaa; }
|
||||
.stat.rares::after { content: " Rares"; font-size:0.7em; color:#aaa; }
|
||||
/* metastate pill colors are assigned dynamically: green for “good” states, red otherwise */
|
||||
#playerList li .stat.meta {
|
||||
/* fallback */
|
||||
.stat.meta {
|
||||
background: var(--accent);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
#playerList li .stat.meta.green {
|
||||
background: #2ecc71; /* pleasant green */
|
||||
color: #111;
|
||||
}
|
||||
|
||||
#playerList li .stat.meta.red {
|
||||
background: #e74c3c; /* vivid red */
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ---------- chat window styling ------------------------------- */
|
||||
.chat-btn {
|
||||
margin-top: 4px;
|
||||
padding: 2px 6px;
|
||||
background: var(--accent);
|
||||
color: #111;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-window {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
/* position window to start just right of the sidebar */
|
||||
left: calc(var(--sidebar-width) + 10px);
|
||||
/* increase default size for better usability */
|
||||
width: 760px; /* increased width for larger terminal area */
|
||||
height: 300px;
|
||||
background: var(--card);
|
||||
border: 1px solid #555;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--accent);
|
||||
padding: 4px;
|
||||
color: #111;
|
||||
cursor: move; /* indicates the header is draggable */
|
||||
}
|
||||
|
||||
.chat-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: #fff;
|
||||
/* reserve space so messages aren't hidden behind the input */
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.chat-form {
|
||||
display: flex;
|
||||
border-top: 1px solid #333;
|
||||
/* fix input area to the bottom of the chat window */
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #333;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
padding: 4px 6px;
|
||||
border: none;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Prevent text selection while dragging chat windows */
|
||||
body.noselect, body.noselect * {
|
||||
user-select: none !important;
|
||||
}
|
||||
.stat.onlinetime::before { content: "🕑 "}
|
||||
.stat.deaths::before { content: "💀 "}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue