diff --git a/README.md b/README.md index a4d1aaca..50a7849d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Dereth Tracker -Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a SQLite database, and provides both a live map interface and an analytics dashboard. +Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a SQLite database, and provides a live map interface along with a sample data generator for testing. ## Table of Contents - [Overview](#overview) @@ -12,7 +12,6 @@ Dereth Tracker is a real-time telemetry service for the world of Dereth. It coll - [API Reference](#api-reference) - [Frontend](#frontend) - [Database Schema](#database-schema) -- [Sample Payload](#sample-payload) - [Contributing](#contributing) ## Overview @@ -21,16 +20,16 @@ This project provides: - A FastAPI backend with endpoints for receiving and querying telemetry data. - SQLite-based storage for snapshots and live state. - A live, interactive map using static HTML, CSS, and JavaScript. -- An analytics dashboard for visualizing kills and session metrics. +- A sample data generator script (`generate_data.py`) for simulating telemetry snapshots. ## Features -- **POST /position**: Submit a telemetry snapshot (protected by a shared secret). +- **WebSocket /ws/position**: Stream telemetry snapshots (protected by a shared secret). - **GET /live**: Fetch active players seen in the last 30 seconds. - **GET /history**: Retrieve historical telemetry data with optional time filtering. - **GET /debug**: Health check endpoint. - **Live Map**: Interactive map interface with panning, zooming, and sorting. - - **Analytics Dashboard**: Interactive charts for kills over time and kills per hour using D3.js. +- **Sample Data Generator**: `generate_data.py` sends telemetry snapshots over WebSocket for testing. ## Requirements @@ -45,6 +44,7 @@ Python packages: - pydantic - pandas - matplotlib +- websockets # required for sample data generator ## Installation @@ -60,13 +60,14 @@ Python packages: ``` 3. Install dependencies: ```bash - pip install fastapi uvicorn pydantic pandas matplotlib + pip install fastapi uvicorn pydantic pandas matplotlib websockets ``` ## Configuration - Update the `SHARED_SECRET` in `main.py` to match your plugin (default: `"your_shared_secret"`). - The SQLite database file `dereth.db` is created in the project root. To change the path, edit `DB_FILE` in `db.py`. +- To limit the maximum database size, set the environment variable `DB_MAX_SIZE_MB` (default: 2048 MB). ## Usage @@ -76,17 +77,66 @@ Start the server using Uvicorn: uvicorn main:app --reload --host 0.0.0.0 --port 8000 ``` -- Live Map: `http://localhost:8000/` -- Analytics Dashboard: `http://localhost:8000/graphs.html` +## NGINX Proxy Configuration + +If you cannot reassign the existing `/live` and `/trails` routes, you can namespace this service under `/api` (or any other prefix) and configure NGINX accordingly. Be sure to forward WebSocket upgrade headers so that `/ws/live` and `/ws/position` continue to work. Example: +```nginx +location /api/ { + proxy_pass http://127.0.0.1:8765/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # WebSocket support + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_cache_bypass $http_upgrade; +} +``` +Then the browser client (static/script.js) will fetch `/api/live/` and `/api/trails/` to reach this new server. + +- Live Map: `http://localhost:8000/` (or `http:///api/` if behind a prefix) + +### Frontend Configuration + +- In `static/script.js`, the constant `API_BASE` controls where live/trails data and WebSocket `/ws/live` are fetched. By default: + ```js + const API_BASE = '/api'; + ``` + Update `API_BASE` if you mount the service under a different path or serve it at root. + +### Debugging WebSockets + +- Server logs now print every incoming WebSocket frame in `main.py`: + - `[WS-PLUGIN RX] : ` for plugin messages on `/ws/position` + - `[WS-LIVE RX] : ` for browser messages on `/ws/live` +- Use these logs to verify messages and troubleshoot handshake failures. + +### Styling Adjustments + +- Chat input bar is fixed at the bottom of the chat window (`.chat-form { position:absolute; bottom:0; }`). +- Input text and placeholder are white for readability (`.chat-input, .chat-input::placeholder { color:#fff; }`). +- Incoming chat messages forced white via `.chat-messages div { color:#fff !important; }`. ## API Reference -### POST /position -Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: `. +### WebSocket /ws/position +Stream telemetry snapshots over a WebSocket connection. Provide your shared secret either as a query parameter or WebSocket header: + +``` +ws://:/ws/position?secret= +``` +or +``` +X-Plugin-Secret: +``` + +After connecting, send JSON messages matching the `TelemetrySnapshot` schema. For example: -**Request Body Example:** ```json { + "type": "telemetry", "character_name": "Dunking Rares", "char_tag": "moss", "session_id": "dunk-20250422-xyz", @@ -104,6 +154,23 @@ Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: None: """Create tables if they do not exist (extended with kills_per_hour and onlinetime).""" - conn = sqlite3.connect(DB_FILE) + # Open connection with a longer timeout + conn = sqlite3.connect(DB_FILE, timeout=30) + # Bump SQLite runtime limits + conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, DB_MAX_SQL_LENGTH) + conn.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, DB_MAX_SQL_LENGTH) + conn.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, DB_MAX_SQL_VARIABLES) c = conn.cursor() + # Enable auto_vacuum FULL and rebuild DB so that deletions shrink the file + c.execute("PRAGMA auto_vacuum=FULL;") + conn.commit() + c.execute("VACUUM;") + conn.commit() + # Switch to WAL mode for concurrency, adjust checkpointing, and enforce max size + c.execute("PRAGMA journal_mode=WAL") + c.execute("PRAGMA synchronous=NORMAL") + c.execute(f"PRAGMA wal_autocheckpoint={DB_WAL_AUTOCHECKPOINT_PAGES}") # History log c.execute( @@ -60,8 +85,17 @@ def init_db() -> None: def save_snapshot(data: Dict) -> None: """Insert snapshot into history and upsert into live_state (with new fields).""" - conn = sqlite3.connect(DB_FILE) + # Open connection with a longer busy timeout + conn = sqlite3.connect(DB_FILE, timeout=30) + # Bump SQLite runtime limits on this connection + conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, DB_MAX_SQL_LENGTH) + conn.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, DB_MAX_SQL_LENGTH) + conn.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, DB_MAX_SQL_VARIABLES) c = conn.cursor() + # Ensure WAL mode, checkpointing, and size limit on this connection + c.execute("PRAGMA journal_mode=WAL") + c.execute("PRAGMA synchronous=NORMAL") + c.execute(f"PRAGMA wal_autocheckpoint={DB_WAL_AUTOCHECKPOINT_PAGES}") # Insert full history row c.execute( diff --git a/generate_data.py b/generate_data.py index fdbbfd1a..d5eb674f 100644 --- a/generate_data.py +++ b/generate_data.py @@ -1,45 +1,45 @@ -import httpx +import asyncio +import websockets +import json from datetime import datetime, timedelta, timezone -from time import sleep from main import TelemetrySnapshot -def main() -> None: +async def main() -> None: wait = 10 online_time = 24 * 3600 # start at 1 day - ew = 0 - ns = 0 - 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 + 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 if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/main.py b/main.py index fdb48928..05b5c562 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketD from fastapi.responses import JSONResponse from fastapi.routing import APIRoute from fastapi.staticfiles import StaticFiles +from fastapi.encoders import jsonable_encoder from pydantic import BaseModel from typing import Optional @@ -50,31 +51,6 @@ def on_startup(): init_db() -# ------------------------ POST ---------------------------------- -@app.post("/position") -@app.post("/position/") -async def receive_snapshot( - snapshot: TelemetrySnapshot, x_plugin_secret: str = Header(None) -): - if x_plugin_secret != SHARED_SECRET: - raise HTTPException(status_code=401, detail="Unauthorized") - - # cache for /live - live_snapshots[snapshot.character_name] = snapshot.dict() - - # save in sqlite - save_snapshot(snapshot.dict()) - - # optional log-file append - # with open(LOG_FILE, "a") as f: - # f.write(json.dumps(snapshot.dict(), default=str) + "\n") - - print( - f"[{datetime.now()}] {snapshot.character_name} @ NS={snapshot.ns:+.2f}, EW={snapshot.ew:+.2f}" - ) - - return {"status": "ok"} - # ------------------------ GET ----------------------------------- @app.get("/debug") @@ -82,22 +58,33 @@ def debug(): return {"status": "OK"} -@app.get("/live") -@app.get("/live/") +@app.get("/live", response_model=dict) +@app.get("/live/", response_model=dict) def get_live_players(): - conn = sqlite3.connect(DB_FILE) - conn.row_factory = sqlite3.Row - rows = conn.execute("SELECT * FROM live_state").fetchall() - conn.close() + # compute cutoff once + now_utc = datetime.now(timezone.utc) + cutoff = now_utc - ACTIVE_WINDOW - # aware cutoff (UTC) - cutoff = datetime.utcnow().replace(tzinfo=timezone.utc) - ACTIVE_WINDOW + cutoff_sql = cutoff.strftime("%Y-%m-%d %H:%M:%S") - players = [ - dict(r) - for r in rows - if datetime.fromisoformat(r["timestamp"].replace("Z", "+00:00")) > cutoff - ] + 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)) return JSONResponse(content={"players": players}) @@ -198,42 +185,114 @@ def get_trails( # -------------------- WebSocket endpoints ----------------------- browser_conns: set[WebSocket] = set() +# Map of registered plugin clients: character_name -> WebSocket +plugin_conns: Dict[str, WebSocket] = {} async def _broadcast_to_browser_clients(snapshot: dict): + # Ensure all data (e.g. datetime) is JSON-serializable + data = jsonable_encoder(snapshot) for ws in list(browser_conns): try: - await ws.send_json(snapshot) + await ws.send_json(data) except WebSocketDisconnect: browser_conns.remove(ws) @app.websocket("/ws/position") -async def ws_receive_snapshots(websocket: WebSocket, secret: str = Query(...)): - await websocket.accept() - if secret != SHARED_SECRET: +async def ws_receive_snapshots( + websocket: WebSocket, + secret: str | None = Query(None), + x_plugin_secret: str | None = Header(None) +): + # Verify shared secret from query parameter or header + key = secret or x_plugin_secret + if key != SHARED_SECRET: + # Reject without completing the WebSocket handshake await websocket.close(code=1008) return + # Accept the WebSocket connection + await websocket.accept() + print(f"[WS] Plugin connected: {websocket.client}") try: while True: - data = await websocket.receive_json() - snap = TelemetrySnapshot.parse_obj(data) - 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 + # 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}") @app.websocket("/ws/live") async def ws_live_updates(websocket: WebSocket): + # Browser clients connect here to receive telemetry and chat, and send commands await websocket.accept() browser_conns.add(websocket) try: while True: - await asyncio.sleep(3600) + # Receive command messages from browser + try: + data = await websocket.receive_json() + # Debug: log all incoming browser WebSocket messages + print(f"[WS-LIVE RX] {websocket.client}: {data}") + except WebSocketDisconnect: + break + # Determine command envelope format (new or legacy) + if "player_name" in data and "command" in data: + # New format: { player_name, command } + target_name = data["player_name"] + payload = data + elif data.get("type") == "command" and "character_name" in data and "text" in data: + # Legacy format: { type: 'command', character_name, text } + target_name = data.get("character_name") + payload = {"player_name": target_name, "command": data.get("text")} + else: + # Not a recognized command envelope + continue + # Forward command envelope to the appropriate plugin WebSocket + target_ws = plugin_conns.get(target_name) + if target_ws: + await target_ws.send_json(payload) except WebSocketDisconnect: + pass + finally: browser_conns.remove(websocket) # -------------------- static frontend --------------------------- +# static frontend app.mount("/", StaticFiles(directory="static", html=True), name="static") # list routes for convenience diff --git a/static/script.js b/static/script.js index bfe94b72..574e22ea 100644 --- a/static/script.js +++ b/static/script.js @@ -8,6 +8,11 @@ const list = document.getElementById('playerList'); const btnContainer = document.getElementById('sortButtons'); const tooltip = document.getElementById('tooltip'); +// WebSocket for chat and commands +let socket; +// Keep track of open chat windows: character_name -> DOM element +const chatWindows = {}; + /* ---------- constants ------------------------------------------- */ const MAX_Z = 10; const FOCUS_ZOOM = 3; // zoom level when you click a name @@ -19,6 +24,44 @@ const MAP_BOUNDS = { south: -102.00 }; +// Base path for tracker API endpoints; prefix API calls with '/api' when served behind a proxy +const API_BASE = '/api'; +// Maximum number of lines to retain in each chat window scrollback +const MAX_CHAT_LINES = 1000; +// Map numeric chat color codes to CSS hex colors +const CHAT_COLOR_MAP = { + 0: '#00FF00', // Broadcast + 2: '#FFFFFF', // Speech + 3: '#FFD700', // Tell + 4: '#CCCC00', // OutgoingTell + 5: '#FF00FF', // System + 6: '#FF0000', // Combat + 7: '#00CCFF', // Magic + 8: '#DDDDDD', // Channel + 9: '#FF9999', // ChannelSend + 10: '#FFFF33', // Social + 11: '#CCFF33', // SocialSend + 12: '#FFFFFF', // Emote + 13: '#00FFFF', // Advancement + 14: '#66CCFF', // Abuse + 15: '#FF0000', // Help + 16: '#33FF00', // Appraisal + 17: '#0099FF', // Spellcasting + 18: '#FF6600', // Allegiance + 19: '#CC66FF', // Fellowship + 20: '#00FF00', // WorldBroadcast + 21: '#FF0000', // CombatEnemy + 22: '#FF33CC', // CombatSelf + 23: '#00CC00', // Recall + 24: '#00FF00', // Craft + 25: '#00FF66', // Salvaging + 27: '#FFFFFF', // General + 28: '#33FF33', // Trade + 29: '#CCCCCC', // LFG + 30: '#CC00CC', // Roleplay + 31: '#FFFF00' // AdminTell +}; + /* ---------- sort configuration ---------------------------------- */ const sortOptions = [ { @@ -132,8 +175,8 @@ function hideTooltip() { async function pollLive() { try { const [liveRes, trailsRes] = await Promise.all([ - fetch('/live/'), - fetch('/trails/?seconds=600'), + fetch(`${API_BASE}/live/`), + fetch(`${API_BASE}/trails/?seconds=600`), ]); const { players } = await liveRes.json(); const { trails } = await trailsRes.json(); @@ -162,6 +205,7 @@ img.onload = () => { } fitToWindow(); startPolling(); + initWebSocket(); }; /* ---------- rendering sorted list & dots ------------------------ */ @@ -215,6 +259,15 @@ function render(players) { li.addEventListener('click', () => selectPlayer(p, x, y)); if (p.character_name === selected) li.classList.add('selected'); + // Chat button + const chatBtn = document.createElement('button'); + chatBtn.className = 'chat-btn'; + chatBtn.textContent = 'Chat'; + chatBtn.addEventListener('click', e => { + e.stopPropagation(); + showChatWindow(p.character_name); + }); + li.appendChild(chatBtn); list.appendChild(li); }); } @@ -253,6 +306,103 @@ function selectPlayer(p, x, y) { renderList(); // keep sorted + highlight } +/* ---------- chat & command handlers ---------------------------- */ +// Initialize WebSocket for chat and commands +function initWebSocket() { + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${location.host}${API_BASE}/ws/live`; + socket = new WebSocket(wsUrl); + socket.addEventListener('message', evt => { + let msg; + try { msg = JSON.parse(evt.data); } catch { return; } + if (msg.type === 'chat') { + appendChatMessage(msg); + } + }); + socket.addEventListener('close', () => setTimeout(initWebSocket, 2000)); + socket.addEventListener('error', e => console.error('WebSocket error:', e)); +} + +// Display or create a chat window for a character +function showChatWindow(name) { + if (chatWindows[name]) { + // Restore flex layout when reopening + chatWindows[name].style.display = 'flex'; + return; + } + const win = document.createElement('div'); + win.className = 'chat-window'; + win.dataset.character = name; + // Header + const header = document.createElement('div'); + header.className = 'chat-header'; + const title = document.createElement('span'); + title.textContent = `Chat: ${name}`; + const closeBtn = document.createElement('button'); + closeBtn.className = 'chat-close-btn'; + closeBtn.textContent = '×'; + closeBtn.addEventListener('click', () => { win.style.display = 'none'; }); + header.appendChild(title); + header.appendChild(closeBtn); + win.appendChild(header); + // Messages container + const msgs = document.createElement('div'); + msgs.className = 'chat-messages'; + win.appendChild(msgs); + // Input form + const form = document.createElement('form'); + form.className = 'chat-form'; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'chat-input'; + input.placeholder = 'Enter chat...'; + form.appendChild(input); + form.addEventListener('submit', e => { + e.preventDefault(); + const text = input.value.trim(); + if (!text) return; + // Send command envelope: player_name and command only + socket.send(JSON.stringify({ player_name: name, command: text })); + input.value = ''; + }); + win.appendChild(form); + document.body.appendChild(win); + chatWindows[name] = win; +} + +// Append a chat message to the correct window +/** + * Append a chat message to the correct window, optionally coloring the text. + * msg: { type: 'chat', character_name, text, color? } + */ +function appendChatMessage(msg) { + const { character_name: name, text, color } = msg; + const win = chatWindows[name]; + if (!win) return; + const msgs = win.querySelector('.chat-messages'); + const p = document.createElement('div'); + if (color !== undefined) { + let c = color; + if (typeof c === 'number') { + // map numeric chat code to configured color, or fallback to raw hex + if (CHAT_COLOR_MAP.hasOwnProperty(c)) { + c = CHAT_COLOR_MAP[c]; + } else { + c = '#' + c.toString(16).padStart(6, '0'); + } + } + p.style.color = c; + } + p.textContent = text; + msgs.appendChild(p); + // Enforce max number of lines in scrollback + while (msgs.children.length > MAX_CHAT_LINES) { + msgs.removeChild(msgs.firstChild); + } + // Scroll to bottom + msgs.scrollTop = msgs.scrollHeight; +} + /* ---------- pan & zoom handlers -------------------------------- */ wrap.addEventListener('wheel', e => { e.preventDefault(); diff --git a/static/style.css b/static/style.css index b6a7286d..dc8e2e77 100644 --- a/static/style.css +++ b/static/style.css @@ -7,6 +7,11 @@ --text: #eee; --accent: #88f; } +/* Placeholder text in chat input should be white */ +.chat-input::placeholder { + color: #fff; + opacity: 0.7; +} html { margin: 0; @@ -201,6 +206,81 @@ body { background: var(--accent); color: #111; } + +/* ---------- chat window styling ------------------------------- */ +.chat-btn { + margin-top: 4px; + padding: 2px 6px; + background: var(--accent); + color: #111; + border: none; + border-radius: 3px; + font-size: 0.75rem; + cursor: pointer; +} + +.chat-window { + position: absolute; + top: 10px; + /* position window to start just right of the sidebar */ + left: calc(var(--sidebar-width) + 10px); + /* increase default size for better usability */ + width: 760px; /* increased width for larger terminal area */ + height: 300px; + background: var(--card); + border: 1px solid #555; + display: flex; + flex-direction: column; + z-index: 10000; +} + +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--accent); + padding: 4px; + color: #111; +} + +.chat-close-btn { + background: transparent; + border: none; + font-size: 1.2rem; + line-height: 1; + cursor: pointer; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 4px; + font-size: 0.85rem; + color: #fff; + /* reserve space so messages aren't hidden behind the input */ + padding-bottom: 40px; +} + +.chat-form { + display: flex; + border-top: 1px solid #333; + /* fix input area to the bottom of the chat window */ + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: #333; + z-index: 10; +} + +.chat-input { + flex: 1; + padding: 4px 6px; + border: none; + background: #333; + color: #fff; + outline: none; +} .stat.onlinetime::before { content: "🕑 "} .stat.deaths::before { content: "💀 "}