From f218350959ddfa3bcf8de5af1cfba342c292c39a Mon Sep 17 00:00:00 2001 From: erik Date: Sun, 8 Jun 2025 20:51:06 +0000 Subject: [PATCH] Major overhaul of db -> hypertable conversion, updated GUI, added inventory --- db_async.py | 28 +- .../dashboards/dereth_tracker_dashboard.json | 14 +- main.py | 439 +++++++++-- static/index.html | 1 + static/prismatic-taper-icon.png | Bin 0 -> 924 bytes static/script.js | 397 ++++++---- static/style-ac.css | 731 ++++++++++++++++++ static/style.css | 165 +++- 8 files changed, 1565 insertions(+), 210 deletions(-) create mode 100644 static/prismatic-taper-icon.png create mode 100644 static/style-ac.css diff --git a/db_async.py b/db_async.py index fecee594..20001b8d 100644 --- a/db_async.py +++ b/db_async.py @@ -7,12 +7,12 @@ import os import sqlalchemy from databases import Database from sqlalchemy import MetaData, Table, Column, Integer, String, Float, DateTime, text -from sqlalchemy import Index +from sqlalchemy import Index, BigInteger, JSON, Boolean, UniqueConstraint # Environment: Postgres/TimescaleDB connection URL DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth") -# Async database client -database = Database(DATABASE_URL) +# Async database client with explicit connection pool configuration +database = Database(DATABASE_URL, min_size=5, max_size=20) # Metadata for SQLAlchemy Core # SQLAlchemy metadata container for table definitions metadata = MetaData() @@ -35,6 +35,7 @@ telemetry_events = Table( Column("kills_per_hour", Float, nullable=True), Column("onlinetime", String, nullable=True), Column("deaths", Integer, nullable=False), + Column("total_deaths", Integer, nullable=True), Column("rares_found", Integer, nullable=False), Column("prismatic_taper_count", Integer, nullable=False), Column("vt_state", String, nullable=True), @@ -104,6 +105,27 @@ rare_events = Table( Column("z", Float, nullable=False), ) +character_inventories = Table( + # Stores complete character inventory snapshots with searchable fields + "character_inventories", + metadata, + Column("id", Integer, primary_key=True), + Column("character_name", String, nullable=False, index=True), + Column("item_id", BigInteger, nullable=False), + Column("timestamp", DateTime(timezone=True), nullable=False), + # Extracted searchable fields + Column("name", String), + Column("icon", Integer), + Column("object_class", Integer, index=True), + Column("value", Integer, index=True), + Column("burden", Integer), + Column("has_id_data", Boolean), + # Complete item data as JSONB + Column("item_data", JSON, nullable=False), + # Unique constraint to prevent duplicate items per character + UniqueConstraint("character_name", "item_id", name="uq_char_item"), +) + async def init_db_async(): """Initialize PostgreSQL/TimescaleDB schema and hypertable. diff --git a/grafana/dashboards/dereth_tracker_dashboard.json b/grafana/dashboards/dereth_tracker_dashboard.json index 4ec57674..6c886790 100644 --- a/grafana/dashboards/dereth_tracker_dashboard.json +++ b/grafana/dashboards/dereth_tracker_dashboard.json @@ -4,7 +4,11 @@ "title": "Dereth Tracker Dashboard", "schemaVersion": 30, "version": 1, - "refresh": "10s", + "refresh": "30s", + "time": { + "from": "now-24h", + "to": "now" + }, "panels": [ { "type": "timeseries", @@ -17,7 +21,7 @@ "refId": "A", "format": "time_series", "datasource": { "uid": "dereth-db" }, - "rawSql": "SELECT $__time(timestamp), kills_per_hour AS value FROM telemetry_events WHERE character_name = '$character' ORDER BY timestamp" + "rawSql": "SELECT $__time(timestamp), kills_per_hour AS value FROM telemetry_events WHERE character_name = '$character' AND $__timeFilter(timestamp) ORDER BY timestamp" } ] }, @@ -32,7 +36,7 @@ "refId": "A", "format": "time_series", "datasource": { "uid": "dereth-db" }, - "rawSql": "SELECT $__time(timestamp), mem_mb AS value FROM telemetry_events WHERE character_name = '$character' ORDER BY timestamp" + "rawSql": "SELECT $__time(timestamp), mem_mb AS value FROM telemetry_events WHERE character_name = '$character' AND $__timeFilter(timestamp) ORDER BY timestamp" } ] }, @@ -47,7 +51,7 @@ "refId": "A", "format": "time_series", "datasource": { "uid": "dereth-db" }, - "rawSql": "SELECT $__time(timestamp), cpu_pct AS value FROM telemetry_events WHERE character_name = '$character' ORDER BY timestamp" + "rawSql": "SELECT $__time(timestamp), cpu_pct AS value FROM telemetry_events WHERE character_name = '$character' AND $__timeFilter(timestamp) ORDER BY timestamp" } ] }, @@ -62,7 +66,7 @@ "refId": "A", "format": "time_series", "datasource": { "uid": "dereth-db" }, - "rawSql": "SELECT $__time(timestamp), mem_handles AS value FROM telemetry_events WHERE character_name = '$character' ORDER BY timestamp" + "rawSql": "SELECT $__time(timestamp), mem_handles AS value FROM telemetry_events WHERE character_name = '$character' AND $__timeFilter(timestamp) ORDER BY timestamp" } ] } diff --git a/main.py b/main.py index eb868027..f0b6cae2 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,8 @@ import json import logging import os import sys -from typing import Dict +from typing import Dict, List, Any +from pathlib import Path from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi.responses import JSONResponse @@ -30,6 +31,7 @@ from db_async import ( rare_stats_sessions, spawn_events, rare_events, + character_inventories, init_db_async ) import asyncio @@ -54,6 +56,9 @@ _cache_task: asyncio.Task | None = None async def _refresh_cache_loop() -> None: """Background task: refresh `/live` and `/trails` caches every 5 seconds.""" + consecutive_failures = 0 + max_consecutive_failures = 5 + while True: try: # Recompute live players (last 30s) @@ -61,7 +66,8 @@ async def _refresh_cache_loop() -> None: sql_live = """ SELECT sub.*, COALESCE(rs.total_rares, 0) AS total_rares, - COALESCE(rss.session_rares, 0) AS session_rares + COALESCE(rss.session_rares, 0) AS session_rares, + COALESCE(cs.total_kills, 0) AS total_kills FROM ( SELECT DISTINCT ON (character_name) * FROM telemetry_events @@ -73,25 +79,52 @@ async def _refresh_cache_loop() -> None: LEFT JOIN rare_stats_sessions rss ON sub.character_name = rss.character_name AND sub.session_id = rss.session_id + LEFT JOIN char_stats cs + ON sub.character_name = cs.character_name """ - rows = await database.fetch_all(sql_live, {"cutoff": cutoff}) - _cached_live["players"] = [dict(r) for r in rows] - # Recompute trails (last 600s) - cutoff2 = datetime.utcnow().replace(tzinfo=timezone.utc) - timedelta(seconds=600) - sql_trail = """ - SELECT timestamp, character_name, ew, ns, z - FROM telemetry_events - WHERE timestamp >= :cutoff - ORDER BY character_name, timestamp - """ - rows2 = await database.fetch_all(sql_trail, {"cutoff": cutoff2}) - _cached_trails["trails"] = [ - {"timestamp": r["timestamp"], "character_name": r["character_name"], - "ew": r["ew"], "ns": r["ns"], "z": r["z"]} - for r in rows2 - ] + + # Use a single connection for both queries to reduce connection churn + async with database.connection() as conn: + rows = await conn.fetch_all(sql_live, {"cutoff": cutoff}) + _cached_live["players"] = [dict(r) for r in rows] + + # Recompute trails (last 600s) + cutoff2 = datetime.utcnow().replace(tzinfo=timezone.utc) - timedelta(seconds=600) + sql_trail = """ + SELECT timestamp, character_name, ew, ns, z + FROM telemetry_events + WHERE timestamp >= :cutoff + ORDER BY character_name, timestamp + """ + rows2 = await conn.fetch_all(sql_trail, {"cutoff": cutoff2}) + _cached_trails["trails"] = [ + {"timestamp": r["timestamp"], "character_name": r["character_name"], + "ew": r["ew"], "ns": r["ns"], "z": r["z"]} + for r in rows2 + ] + + # Reset failure counter on success + consecutive_failures = 0 + logger.debug(f"Cache refreshed: {len(_cached_live['players'])} players, {len(_cached_trails['trails'])} trail points") + except Exception as e: - logger.error(f"Cache refresh failed: {e}", exc_info=True) + consecutive_failures += 1 + logger.error(f"Cache refresh failed ({consecutive_failures}/{max_consecutive_failures}): {e}", exc_info=True) + + # If too many consecutive failures, wait longer and try to reconnect + if consecutive_failures >= max_consecutive_failures: + logger.warning(f"Too many consecutive cache refresh failures. Attempting database reconnection...") + try: + await database.disconnect() + await asyncio.sleep(2) + await database.connect() + logger.info("Database reconnected successfully") + consecutive_failures = 0 + except Exception as reconnect_error: + logger.error(f"Database reconnection failed: {reconnect_error}") + await asyncio.sleep(10) # Wait longer before retrying + continue + await asyncio.sleep(5) # ------------------------------------------------------------------ @@ -127,6 +160,7 @@ class TelemetrySnapshot(BaseModel): kills_per_hour: Optional[float] = None onlinetime: Optional[str] = None deaths: int + total_deaths: Optional[int] = None # Removed from telemetry payload; always enforced to 0 and tracked via rare events rares_found: int = 0 prismatic_taper_count: int @@ -163,6 +197,17 @@ class RareEvent(BaseModel): z: float = 0.0 +class FullInventoryMessage(BaseModel): + """ + Model for the full_inventory WebSocket message type. + Contains complete character inventory snapshot with raw item data. + """ + character_name: str + timestamp: datetime + item_count: int + items: List[Dict[str, Any]] + + @app.on_event("startup") async def on_startup(): """Event handler triggered when application starts up. @@ -176,6 +221,11 @@ async def on_startup(): await database.connect() await init_db_async() logger.info(f"Database connected successfully on attempt {attempt}") + # Log connection pool configuration + try: + logger.info(f"Database connection established with pool configuration") + except Exception as pool_error: + logger.debug(f"Could not access pool details: {pool_error}") break except Exception as e: logger.warning(f"Database connection failed (attempt {attempt}/{max_attempts}): {e}") @@ -239,6 +289,140 @@ async def get_trails( logger.error(f"Failed to get trails: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") + +# --- GET Inventory Endpoints --------------------------------- +@app.get("/inventory/{character_name}") +async def get_character_inventory(character_name: str): + """Get the complete inventory for a specific character from the database.""" + try: + query = """ + SELECT name, icon, object_class, value, burden, has_id_data, item_data, timestamp + FROM character_inventories + WHERE character_name = :character_name + ORDER BY name + """ + rows = await database.fetch_all(query, {"character_name": character_name}) + + if not rows: + raise HTTPException(status_code=404, detail=f"No inventory found for character '{character_name}'") + + items = [] + for row in rows: + item = dict(row) + items.append(item) + + return JSONResponse(content=jsonable_encoder({ + "character_name": character_name, + "item_count": len(items), + "items": items + })) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get inventory for {character_name}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + +@app.get("/inventory/{character_name}/search") +async def search_character_inventory( + character_name: str, + name: str = Query(None, description="Search by item name (partial match)"), + object_class: int = Query(None, description="Filter by ObjectClass"), + min_value: int = Query(None, description="Minimum item value"), + max_value: int = Query(None, description="Maximum item value"), + min_burden: int = Query(None, description="Minimum burden"), + max_burden: int = Query(None, description="Maximum burden") +): + """Search and filter inventory items for a character with various criteria.""" + try: + conditions = ["character_name = :character_name"] + params = {"character_name": character_name} + + if name: + conditions.append("name ILIKE :name") + params["name"] = f"%{name}%" + + if object_class is not None: + conditions.append("object_class = :object_class") + params["object_class"] = object_class + + if min_value is not None: + conditions.append("value >= :min_value") + params["min_value"] = min_value + + if max_value is not None: + conditions.append("value <= :max_value") + params["max_value"] = max_value + + if min_burden is not None: + conditions.append("burden >= :min_burden") + params["min_burden"] = min_burden + + if max_burden is not None: + conditions.append("burden <= :max_burden") + params["max_burden"] = max_burden + + query = f""" + SELECT name, icon, object_class, value, burden, has_id_data, item_data, timestamp + FROM character_inventories + WHERE {' AND '.join(conditions)} + ORDER BY value DESC, name + """ + + rows = await database.fetch_all(query, params) + + items = [] + for row in rows: + item = dict(row) + items.append(item) + + return JSONResponse(content=jsonable_encoder({ + "character_name": character_name, + "item_count": len(items), + "search_criteria": { + "name": name, + "object_class": object_class, + "min_value": min_value, + "max_value": max_value, + "min_burden": min_burden, + "max_burden": max_burden + }, + "items": items + })) + except Exception as e: + logger.error(f"Failed to search inventory for {character_name}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + +@app.get("/inventories") +async def list_characters_with_inventories(): + """List all characters that have stored inventories with item counts.""" + try: + query = """ + SELECT character_name, COUNT(*) as item_count, MAX(timestamp) as last_updated + FROM character_inventories + GROUP BY character_name + ORDER BY last_updated DESC + """ + rows = await database.fetch_all(query) + + characters = [] + for row in rows: + characters.append({ + "character_name": row["character_name"], + "item_count": row["item_count"], + "last_updated": row["last_updated"] + }) + + return JSONResponse(content=jsonable_encoder({ + "characters": characters, + "total_characters": len(characters) + })) + except Exception as e: + logger.error(f"Failed to list inventory characters: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + # -------------------- WebSocket endpoints ----------------------- ## WebSocket connection tracking # Set of browser WebSocket clients subscribed to live updates @@ -250,15 +434,99 @@ async def _broadcast_to_browser_clients(snapshot: dict): """Broadcast a telemetry or chat message to all connected browser clients. Converts any non-serializable types (e.g., datetime) before sending. + Handles connection errors gracefully and removes stale connections. """ # Convert snapshot payload to JSON-friendly types data = jsonable_encoder(snapshot) + # Use list() to avoid "set changed size during iteration" errors + disconnected_clients = [] + for ws in list(browser_conns): try: await ws.send_json(data) - except (WebSocketDisconnect, RuntimeError) as e: - browser_conns.discard(ws) - logger.debug(f"Removed disconnected browser client from broadcast list: {e}") + except (WebSocketDisconnect, RuntimeError, ConnectionAbortedError) as e: + # Collect disconnected clients for cleanup + disconnected_clients.append(ws) + logger.debug(f"Detected disconnected browser client: {e}") + except Exception as e: + # Handle any other unexpected errors + disconnected_clients.append(ws) + logger.warning(f"Unexpected error broadcasting to browser client: {e}") + + # Clean up disconnected clients + for ws in disconnected_clients: + browser_conns.discard(ws) + + +async def _store_inventory(inventory_msg: FullInventoryMessage): + """Store complete character inventory to database with extracted searchable fields. + + Processes each item to extract key fields for indexing while preserving + complete item data in JSONB format. Uses UPSERT to handle item updates. + """ + try: + # Create inventory directory if it doesn't exist + inventory_dir = Path("./inventory") + inventory_dir.mkdir(exist_ok=True) + + # Store as JSON file for backwards compatibility / debugging + file_path = inventory_dir / f"{inventory_msg.character_name}_inventory.json" + inventory_data = { + "character_name": inventory_msg.character_name, + "timestamp": inventory_msg.timestamp.isoformat(), + "item_count": inventory_msg.item_count, + "items": inventory_msg.items + } + + with open(file_path, 'w') as f: + json.dump(inventory_data, f, indent=2) + + # Store items in database + for item in inventory_msg.items: + # Extract searchable fields + item_id = item.get("Id") + if item_id is None: + continue # Skip items without ID + + name = item.get("Name", "") + icon = item.get("Icon", 0) + object_class = item.get("ObjectClass", 0) + value = item.get("Value", 0) + burden = item.get("Burden", 0) + has_id_data = item.get("HasIdData", False) + + # UPSERT item into database + stmt = pg_insert(character_inventories).values( + character_name=inventory_msg.character_name, + item_id=item_id, + timestamp=inventory_msg.timestamp, + name=name, + icon=icon, + object_class=object_class, + value=value, + burden=burden, + has_id_data=has_id_data, + item_data=item + ).on_conflict_do_update( + constraint="uq_char_item", + set_={ + "timestamp": inventory_msg.timestamp, + "name": name, + "icon": icon, + "object_class": object_class, + "value": value, + "burden": burden, + "has_id_data": has_id_data, + "item_data": item + } + ) + + await database.execute(stmt) + + except Exception as e: + logger.error(f"Failed to store inventory for {inventory_msg.character_name}: {e}", exc_info=True) + raise + @app.websocket("/ws/position") async def ws_receive_snapshots( @@ -336,7 +604,19 @@ async def ws_receive_snapshots( db_data = snap.dict() db_data['rares_found'] = 0 key = (snap.session_id, snap.character_name) - last = ws_receive_snapshots._last_kills.get(key, 0) + + # Get last recorded kill count for this session + if key in ws_receive_snapshots._last_kills: + last = ws_receive_snapshots._last_kills[key] + else: + # Cache miss - check database for last kill count for this session + row = await database.fetch_one( + "SELECT kills FROM telemetry_events WHERE character_name = :char AND session_id = :session ORDER BY timestamp DESC LIMIT 1", + {"char": snap.character_name, "session": snap.session_id} + ) + last = row["kills"] if row else 0 + logger.debug(f"Cache miss for {snap.character_name} session {snap.session_id[:8]}: loaded last_kills={last} from database") + delta = snap.kills - last # Persist snapshot and any kill delta in a single transaction try: @@ -418,6 +698,17 @@ async def ws_receive_snapshots( await _broadcast_to_browser_clients(data) logger.debug(f"Broadcasted chat message from {data.get('character_name', 'unknown')}") continue + # --- Full inventory message: store complete inventory snapshot --- + if msg_type == "full_inventory": + payload = data.copy() + payload.pop("type", None) + try: + inventory_msg = FullInventoryMessage.parse_obj(payload) + await _store_inventory(inventory_msg) + logger.info(f"Stored inventory for {inventory_msg.character_name}: {inventory_msg.item_count} items") + except Exception as e: + logger.error(f"Failed to process inventory for {data.get('character_name', 'unknown')}: {e}", exc_info=True) + continue # Unknown message types are ignored if msg_type: logger.warning(f"Unknown message type '{msg_type}' from {websocket.client}") @@ -425,9 +716,22 @@ async def ws_receive_snapshots( # 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] + # Use pop() instead of del to avoid KeyError if already removed + plugin_conns.pop(n, None) + + # Also clean up any entries in the kill tracking cache for this session + # Remove entries that might be associated with disconnected clients + stale_keys = [] + for (session_id, char_name), _ in ws_receive_snapshots._last_kills.items(): + if char_name in to_remove: + stale_keys.append((session_id, char_name)) + for key in stale_keys: + ws_receive_snapshots._last_kills.pop(key, None) + if to_remove: logger.info(f"Cleaned up plugin connections for characters: {to_remove} from {websocket.client}") + if stale_keys: + logger.debug(f"Cleaned up {len(stale_keys)} kill tracking cache entries") else: logger.debug(f"No plugin registrations to clean up for {websocket.client}") @@ -435,6 +739,44 @@ async def ws_receive_snapshots( # Used to compute deltas for updating persistent kill statistics efficiently ws_receive_snapshots._last_kills = {} +async def cleanup_stale_connections(): + """Periodic cleanup of stale WebSocket connections. + + This function can be called periodically to clean up connections + that may have become stale but weren't properly cleaned up. + """ + # Clean up plugin connections that no longer have valid WebSockets + stale_plugins = [] + for char_name, ws in list(plugin_conns.items()): + try: + # Test if the WebSocket is still alive by checking its state + if ws.client_state.name != 'CONNECTED': + stale_plugins.append(char_name) + except Exception: + # If we can't check the state, consider it stale + stale_plugins.append(char_name) + + for char_name in stale_plugins: + plugin_conns.pop(char_name, None) + logger.info(f"Cleaned up stale plugin connection: {char_name}") + + # Clean up browser connections + stale_browsers = [] + for ws in list(browser_conns): + try: + if ws.client_state.name != 'CONNECTED': + stale_browsers.append(ws) + except Exception: + stale_browsers.append(ws) + + for ws in stale_browsers: + browser_conns.discard(ws) + + if stale_browsers: + logger.info(f"Cleaned up {len(stale_browsers)} stale browser connections") + + logger.debug(f"Connection health check: {len(plugin_conns)} plugins, {len(browser_conns)} browsers") + @app.websocket("/ws/live") async def ws_live_updates(websocket: WebSocket): """WebSocket endpoint for browser clients to receive live updates and send commands. @@ -478,6 +820,10 @@ async def ws_live_updates(websocket: WebSocket): logger.warning(f"Failed to forward command to {target_name}: {e}") # Remove stale connection plugin_conns.pop(target_name, None) + except Exception as e: + logger.error(f"Unexpected error forwarding command to {target_name}: {e}") + # Remove potentially corrupted connection + plugin_conns.pop(target_name, None) else: logger.warning(f"No plugin connection found for target character: {target_name}") except WebSocketDisconnect: @@ -507,32 +853,37 @@ async def get_stats(character_name: str): Returns 404 if character has no recorded telemetry. """ try: - # Latest snapshot - sql_snap = ( - "SELECT * FROM telemetry_events " - "WHERE character_name = :cn " - "ORDER BY timestamp DESC LIMIT 1" - ) - snap = await database.fetch_one(sql_snap, {"cn": character_name}) - if not snap: + # Single optimized query with LEFT JOINs to get all data in one round trip + sql = """ + WITH latest AS ( + SELECT * FROM telemetry_events + WHERE character_name = :cn + ORDER BY timestamp DESC LIMIT 1 + ) + SELECT + l.*, + COALESCE(cs.total_kills, 0) as total_kills, + COALESCE(rs.total_rares, 0) as total_rares + FROM latest l + LEFT JOIN char_stats cs ON l.character_name = cs.character_name + LEFT JOIN rare_stats rs ON l.character_name = rs.character_name + """ + row = await database.fetch_one(sql, {"cn": character_name}) + if not row: logger.warning(f"No telemetry data found for character: {character_name}") raise HTTPException(status_code=404, detail="Character not found") - snap_dict = dict(snap) - # Total kills - sql_kills = "SELECT total_kills FROM char_stats WHERE character_name = :cn" - row_kills = await database.fetch_one(sql_kills, {"cn": character_name}) - total_kills = row_kills["total_kills"] if row_kills else 0 - # Total rares - sql_rares = "SELECT total_rares FROM rare_stats WHERE character_name = :cn" - row_rares = await database.fetch_one(sql_rares, {"cn": character_name}) - total_rares = row_rares["total_rares"] if row_rares else 0 + + # Extract latest snapshot data (exclude the added total_kills/total_rares) + snap_dict = {k: v for k, v in dict(row).items() + if k not in ("total_kills", "total_rares")} + result = { "character_name": character_name, "latest_snapshot": snap_dict, - "total_kills": total_kills, - "total_rares": total_rares, + "total_kills": row["total_kills"], + "total_rares": row["total_rares"], } - logger.debug(f"Retrieved stats for character: {character_name}") + logger.debug(f"Retrieved stats for character: {character_name} (optimized query)") return JSONResponse(content=jsonable_encoder(result)) except HTTPException: raise diff --git a/static/index.html b/static/index.html index 6c87f3e0..12e10e8b 100644 --- a/static/index.html +++ b/static/index.html @@ -31,6 +31,7 @@
+
diff --git a/static/prismatic-taper-icon.png b/static/prismatic-taper-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9c34b9274ccf6d79c24a4a57f40b94612be3831a GIT binary patch literal 924 zcmV;N17rM&P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D12IWNK~z{ry^>u_ zQ)d8%y;*iMFAZX<5m4~2Z1ER$k!X;KGYW1KEks4VFgBeljb+&2tcjr5bWxNI*r;N( z5h_OzK_P{fzZTknfNfNSMN=kA7U#uocJ(Zt1S02{t)Qn*^4z@Nck(?KXBIH)11d#& zE>kwVha&&{9TW{WLN>gIlMhcPLN?4$8IlO;{YhoR48`NR_V#0w5fKY$X|PHkdmHKf zNoB(f6sZ^T!+KDvY?y(#x&>UGdd?P}ig-4Ih^T80#%heMBVMoY@X-FtIPMg_9 zP40byo>78UHx(y;j7We;S6z@2!(FP*xM^=0CSYpEljDd?K>qn=O0RbFt+58{l@9FH zJ{t0y@o5?(6JWj<;Cq{kqrX0&;C2^N~;yMiak`wI~}fV#?u_vw+^} zjSTuzsrT-n>Y)*#Z!ebKJg&X4D;6L!n3>7T;ym0rE9kA!6$f%<|aVg%cH8>7`M4ON_B}TD;@7hCo*4Nmh7vNgHl-l^&ylyL- z5~El^YDx;`a~DWkn}i`sMdiF0np6o4A3H?*N;S1BKH;y{GUWm`eV)W|t${C6(%8Om z35TK=QK()=)9Nh<2}`JpjpkYI5#<6_uS_KKn=CSR?k0KF2DT)8O6sCS3=5W1vT!li zV`6cx->O`|8}xb|n(Te3m#I<5FC$JBNBkTW$#dqjE%7s=qoNcGc%#U1osRUcGRW3w yNZ*}KY)l;U=Bg%+Cy;*oPGv(F{vXo&|LMOKml}P3R53aL0000 { + window.__chatZ += 1; + win.style.zIndex = window.__chatZ; + }; + + header.addEventListener('mousedown', e => { + if (e.target.closest('button')) return; + e.preventDefault(); + currentDragWindow = win; + bringToFront(); + dragStartX = e.clientX; + dragStartY = e.clientY; + dragStartLeft = win.offsetLeft; + dragStartTop = win.offsetTop; + document.body.classList.add('noselect'); + }); + + // Touch support + header.addEventListener('touchstart', e => { + if (e.touches.length !== 1 || e.target.closest('button')) return; + currentDragWindow = win; + bringToFront(); + const t = e.touches[0]; + dragStartX = t.clientX; + dragStartY = t.clientY; + dragStartLeft = win.offsetLeft; + dragStartTop = win.offsetTop; + }); +} + +// Global mouse handlers (only added once) +window.addEventListener('mousemove', e => { + if (!currentDragWindow) return; + const dx = e.clientX - dragStartX; + const dy = e.clientY - dragStartY; + currentDragWindow.style.left = `${dragStartLeft + dx}px`; + currentDragWindow.style.top = `${dragStartTop + dy}px`; +}); + +window.addEventListener('mouseup', () => { + if (currentDragWindow) { + currentDragWindow = null; + document.body.classList.remove('noselect'); + } +}); + +window.addEventListener('touchmove', e => { + if (!currentDragWindow || e.touches.length !== 1) return; + const t = e.touches[0]; + const dx = t.clientX - dragStartX; + const dy = t.clientY - dragStartY; + currentDragWindow.style.left = `${dragStartLeft + dx}px`; + currentDragWindow.style.top = `${dragStartTop + dy}px`; +}); + +window.addEventListener('touchend', () => { + currentDragWindow = null; +}); // Filter input for player names (starts-with filter) let currentFilter = ''; const filterInput = document.getElementById('playerFilter'); @@ -47,6 +115,8 @@ let socket; const chatWindows = {}; // Keep track of open stats windows: character_name -> DOM element const statsWindows = {}; +// Keep track of open inventory windows: character_name -> DOM element +const inventoryWindows = {}; /** * ---------- Application Constants ----------------------------- @@ -61,14 +131,15 @@ const statsWindows = {}; * CHAT_COLOR_MAP: Color mapping for in-game chat channels by channel code */ /* ---------- constants ------------------------------------------- */ -const MAX_Z = 10; +const MAX_Z = 20; const FOCUS_ZOOM = 3; // zoom level when you click a name const POLL_MS = 2000; -const MAP_BOUNDS = { - west : -102.04, - east : 102.19, - north: 102.16, - south: -102.00 +// UtilityBelt's more accurate coordinate bounds +const MAP_BOUNDS = { + west: -102.1, + east: 102.1, + north: 102.1, + south: -102.1 }; // Base path for tracker API endpoints; prefix API calls with '/api' when served behind a proxy @@ -119,8 +190,29 @@ const CHAT_COLOR_MAP = { /* ---------- player/dot color assignment ------------------------- */ // A base palette of distinct, color-blind-friendly colors const PALETTE = [ + // Original colorblind-friendly base palette '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', - '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf' + '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf', + + // Extended high-contrast colors + '#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff', + '#44ffff', '#ff8844', '#88ff44', '#4488ff', '#ff4488', + + // Darker variants + '#cc3333', '#33cc33', '#3333cc', '#cccc33', '#cc33cc', + '#33cccc', '#cc6633', '#66cc33', '#3366cc', '#cc3366', + + // Brighter variants + '#ff6666', '#66ff66', '#6666ff', '#ffff66', '#ff66ff', + '#66ffff', '#ffaa66', '#aaff66', '#66aaff', '#ff66aa', + + // Additional distinct colors + '#990099', '#009900', '#000099', '#990000', '#009999', + '#999900', '#aa5500', '#55aa00', '#0055aa', '#aa0055', + + // Light pastels for contrast + '#ffaaaa', '#aaffaa', '#aaaaff', '#ffffaa', '#ffaaff', + '#aaffff', '#ffccaa', '#ccffaa', '#aaccff', '#ffaacc' ]; // Map from character name to assigned color const colorMap = {}; @@ -158,23 +250,37 @@ function getColorFor(name) { const sortOptions = [ { value: "name", - label: "Name ↑", + label: "Name", comparator: (a, b) => a.character_name.localeCompare(b.character_name) }, { value: "kph", - label: "KPH ↓", + label: "KPH", comparator: (a, b) => b.kills_per_hour - a.kills_per_hour }, { value: "kills", - label: "Kills ↓", + label: "S.Kills", comparator: (a, b) => b.kills - a.kills }, { value: "rares", - label: "Session Rares ↓", + label: "S.Rares", comparator: (a, b) => (b.session_rares || 0) - (a.session_rares || 0) + }, + { + value: "total_kills", + label: "T.Kills", + comparator: (a, b) => (b.total_kills || 0) - (a.total_kills || 0) + }, + { + value: "kpr", + label: "KPR", + comparator: (a, b) => { + const aKpr = (a.total_rares || 0) > 0 ? (a.total_kills || 0) / (a.total_rares || 0) : Infinity; + const bKpr = (b.total_rares || 0) > 0 ? (b.total_kills || 0) / (b.total_rares || 0) : Infinity; + return aKpr - bKpr; // Ascending - lower KPR is better (more efficient rare finding) + } } ]; @@ -226,11 +332,28 @@ function worldToPx(ew, ns) { return { x, y }; } +function pxToWorld(x, y) { + // Convert screen coordinates to map image coordinates + const mapX = (x - offX) / scale; + const mapY = (y - offY) / scale; + + // Convert map image coordinates to world coordinates + const ew = MAP_BOUNDS.west + (mapX / imgW) * (MAP_BOUNDS.east - MAP_BOUNDS.west); + const ns = MAP_BOUNDS.north - (mapY / imgH) * (MAP_BOUNDS.north - MAP_BOUNDS.south); + + return { ew, ns }; +} + // Show or create a stats window for a character function showStatsWindow(name) { if (statsWindows[name]) { const existing = statsWindows[name]; - existing.style.display = 'flex'; + // Toggle: close if already visible, open if hidden + if (existing.style.display === 'flex') { + existing.style.display = 'none'; + } else { + existing.style.display = 'flex'; + } return; } const win = document.createElement('div'); @@ -248,6 +371,29 @@ function showStatsWindow(name) { header.appendChild(title); header.appendChild(closeBtn); win.appendChild(header); + // Time period controls + const controls = document.createElement('div'); + controls.className = 'stats-controls'; + const timeRanges = [ + { label: '1H', value: 'now-1h' }, + { label: '6H', value: 'now-6h' }, + { label: '24H', value: 'now-24h' }, + { label: '7D', value: 'now-7d' } + ]; + timeRanges.forEach(range => { + const btn = document.createElement('button'); + btn.className = 'time-range-btn'; + btn.textContent = range.label; + if (range.value === 'now-24h') btn.classList.add('active'); + btn.addEventListener('click', () => { + controls.querySelectorAll('.time-range-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + updateStatsTimeRange(content, name, range.value); + }); + controls.appendChild(btn); + }); + win.appendChild(controls); + // Content container const content = document.createElement('div'); content.className = 'chat-messages'; @@ -255,7 +401,13 @@ function showStatsWindow(name) { win.appendChild(content); document.body.appendChild(win); statsWindows[name] = win; - // Embed a 2×2 grid of Grafana solo-panel iframes for this character + // Load initial stats with default 24h range + updateStatsTimeRange(content, name, 'now-24h'); + // Enable dragging using the global drag system + makeDraggable(win, header); +} + +function updateStatsTimeRange(content, name, timeRange) { content.innerHTML = ''; const panels = [ { title: 'Kills per Hour', id: 1 }, @@ -269,6 +421,8 @@ function showStatsWindow(name) { `/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard` + `?panelId=${p.id}` + `&var-character=${encodeURIComponent(name)}` + + `&from=${timeRange}` + + `&to=now` + `&theme=light`; iframe.setAttribute('title', p.title); iframe.width = '350'; @@ -277,55 +431,48 @@ function showStatsWindow(name) { iframe.allowFullscreen = true; content.appendChild(iframe); }); - // Enable dragging of the stats window via its header - if (!window.__chatZ) window.__chatZ = 10000; - let drag = false; - let startX = 0, startY = 0, startLeft = 0, startTop = 0; - header.style.cursor = 'move'; - const bringToFront = () => { - window.__chatZ += 1; - win.style.zIndex = window.__chatZ; - }; - header.addEventListener('mousedown', e => { - if (e.target.closest('button')) return; - e.preventDefault(); - drag = true; - bringToFront(); - startX = e.clientX; startY = e.clientY; - 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 for dragging - 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; }); } +// Show or create an inventory window for a character +function showInventoryWindow(name) { + if (inventoryWindows[name]) { + const existing = inventoryWindows[name]; + // Toggle: close if already visible, open if hidden + if (existing.style.display === 'flex') { + existing.style.display = 'none'; + } else { + existing.style.display = 'flex'; + } + return; + } + const win = document.createElement('div'); + win.className = 'inventory-window'; + win.dataset.character = name; + // Header (reuses chat-header styling) + const header = document.createElement('div'); + header.className = 'chat-header'; + const title = document.createElement('span'); + title.textContent = `Inventory: ${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); + // Content container + const content = document.createElement('div'); + content.className = 'inventory-content'; + content.innerHTML = '
Inventory feature coming soon...
'; + win.appendChild(content); + document.body.appendChild(win); + inventoryWindows[name] = win; + + // Enable dragging using the global drag system + makeDraggable(win, header); +} + + const applyTransform = () => group.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`; @@ -446,15 +593,22 @@ function render(players) { const color = getColorFor(p.character_name); li.style.borderLeftColor = color; li.className = 'player-item'; + // Calculate KPR (Kills Per Rare) + const totalKills = p.total_kills || 0; + const totalRares = p.total_rares || 0; + const kpr = totalRares > 0 ? Math.round(totalKills / totalRares) : '∞'; + li.innerHTML = ` - ${p.character_name} - ${loc(p.ns, p.ew)} + ${p.character_name} ${loc(p.ns, p.ew)} ${p.kills} + ${p.total_kills || 0} ${p.kills_per_hour} ${p.session_rares}/${p.total_rares} + ${kpr} ${p.vt_state} ${p.onlinetime} - ${p.deaths} + ${p.deaths}/${p.total_deaths || 0} + ${p.prismatic_taper_count || 0} `; // Color the metastate pill according to its value @@ -489,6 +643,15 @@ function render(players) { showStatsWindow(p.character_name); }); li.appendChild(statsBtn); + // Inventory button + const inventoryBtn = document.createElement('button'); + inventoryBtn.className = 'inventory-btn'; + inventoryBtn.textContent = 'Inventory'; + inventoryBtn.addEventListener('click', e => { + e.stopPropagation(); + showInventoryWindow(p.character_name); + }); + li.appendChild(inventoryBtn); list.appendChild(li); }); } @@ -553,12 +716,17 @@ function initWebSocket() { // 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; + // Toggle: close if already visible, open if hidden + if (existing.style.display === 'flex') { + existing.style.display = 'none'; + } else { + existing.style.display = 'flex'; + // Bring to front when opening + if (!window.__chatZ) window.__chatZ = 10000; + window.__chatZ += 1; + existing.style.zIndex = window.__chatZ; + } return; } const win = document.createElement('div'); @@ -600,76 +768,8 @@ function showChatWindow(name) { 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; - }); + // Enable dragging using the global drag system + makeDraggable(win, header); } // Append a chat message to the correct window @@ -752,3 +852,24 @@ wrap.addEventListener('touchmove', e => { wrap.addEventListener('touchend', () => { dragging = false; }); + +/* ---------- coordinate display on hover ---------------------------- */ +wrap.addEventListener('mousemove', e => { + if (!imgW) return; + + const r = wrap.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + + const { ew, ns } = pxToWorld(x, y); + + // Display coordinates using the same format as the existing loc function + coordinates.textContent = loc(ns, ew); + coordinates.style.left = `${x + 10}px`; + coordinates.style.top = `${y + 10}px`; + coordinates.style.display = 'block'; +}); + +wrap.addEventListener('mouseleave', () => { + coordinates.style.display = 'none'; +}); diff --git a/static/style-ac.css b/static/style-ac.css new file mode 100644 index 00000000..7d30d4cf --- /dev/null +++ b/static/style-ac.css @@ -0,0 +1,731 @@ +/* + * style-ac.css - Asheron's Call themed styles for Dereth Tracker + * + * Recreates the classic AC UI with stone textures, beveled edges, + * golden accents, and medieval fantasy aesthetics. + */ + +/* CSS Custom Properties for AC theme colors and sizing */ +:root { + --sidebar-width: 340px; + + /* AC Color Palette */ + --ac-black: #0a0a0a; + --ac-dark-stone: #1a1a1a; + --ac-medium-stone: #2a2a2a; + --ac-light-stone: #3a3a3a; + --ac-border-dark: #000; + --ac-border-light: #4a4a4a; + --ac-gold: #d4af37; + --ac-gold-bright: #ffd700; + --ac-gold-dark: #b8941f; + --ac-green: #00ff00; + --ac-cyan: #00ffff; + --ac-text: #e0e0e0; + --ac-text-dim: #a0a0a0; + + /* Backgrounds */ + --bg-main: var(--ac-black); + --bg-side: var(--ac-dark-stone); + --card: var(--ac-medium-stone); + --card-hov: var(--ac-light-stone); + --text: var(--ac-text); + --accent: var(--ac-gold); +} + +/* Placeholder text in chat input */ +.chat-input::placeholder { + color: var(--ac-text-dim); + opacity: 0.7; +} + +html { + margin: 0; + height: 100%; + width: 100%; +} + +body { + margin: 0; + height: 100%; + display: flex; + overflow: hidden; + font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif; + background: var(--bg-main); + color: var(--text); + background-image: + repeating-linear-gradient( + 45deg, + transparent, + transparent 10px, + rgba(255, 255, 255, 0.01) 10px, + rgba(255, 255, 255, 0.01) 20px + ); +} + +/* AC-style stone textured panels with beveled edges */ +.ac-panel { + background: linear-gradient(135deg, var(--ac-medium-stone) 0%, var(--ac-dark-stone) 100%); + border: 2px solid var(--ac-border-dark); + box-shadow: + inset 2px 2px 3px rgba(255, 255, 255, 0.1), + inset -2px -2px 3px rgba(0, 0, 0, 0.5), + 0 2px 5px rgba(0, 0, 0, 0.8); + border-radius: 0; +} + +/* Sort buttons - AC style */ +.sort-buttons { + display: flex; + gap: 3px; + margin: 12px 16px 8px; + padding: 8px; + background: var(--ac-dark-stone); + border: 1px solid var(--ac-border-dark); + box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.5); +} + +.sort-buttons .btn { + flex: 1; + padding: 5px 8px; + background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-medium-stone) 100%); + color: var(--ac-text-dim); + border: 1px solid var(--ac-border-dark); + border-radius: 2px; + text-align: center; + cursor: pointer; + user-select: none; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + transition: all 0.15s; + box-shadow: + 1px 1px 2px rgba(0, 0, 0, 0.5), + inset 1px 1px 2px rgba(255, 255, 255, 0.1); +} + +.sort-buttons .btn:hover { + background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-light-stone) 100%); + color: var(--ac-gold-bright); + box-shadow: + 1px 1px 2px rgba(0, 0, 0, 0.5), + inset 1px 1px 2px rgba(255, 255, 255, 0.2), + 0 0 5px rgba(212, 175, 55, 0.3); +} + +.sort-buttons .btn:active { + box-shadow: + inset 2px 2px 3px rgba(0, 0, 0, 0.7), + inset -1px -1px 2px rgba(255, 255, 255, 0.1); +} + +.sort-buttons .btn.active { + background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%); + color: var(--ac-black); + border-color: var(--ac-gold-dark); + font-weight: 700; + position: relative; + box-shadow: + 1px 1px 2px rgba(0, 0, 0, 0.5), + inset 1px 1px 2px rgba(255, 255, 255, 0.3), + 0 0 10px rgba(212, 175, 55, 0.5); +} + +.sort-buttons .btn.active:hover { + background: linear-gradient(180deg, var(--ac-gold-bright) 0%, var(--ac-gold) 100%); + color: var(--ac-black); +} + +/* Sort direction indicators */ +.sort-buttons .btn.active::after { + content: ''; + position: absolute; + top: 3px; + right: 3px; + width: 0; + height: 0; + border-left: 3px solid transparent; + border-right: 3px solid transparent; +} + +/* Most sorts are descending (down arrow) */ +.sort-buttons .btn.active::after { + border-top: 4px solid var(--ac-black); +} + +/* Name and KPR are ascending (up arrow) */ +.sort-buttons .btn.active[data-value="name"]::after, +.sort-buttons .btn.active[data-value="kpr"]::after { + border-top: none; + border-bottom: 4px solid var(--ac-black); +} + +/* Sidebar - AC stone panel style */ +#sidebar { + width: var(--sidebar-width); + scrollbar-width: thin; + scrollbar-color: var(--ac-gold-dark) var(--ac-dark-stone); + background: linear-gradient(180deg, var(--ac-dark-stone) 0%, var(--ac-black) 100%); + border-right: 3px solid var(--ac-border-dark); + box-shadow: + inset -2px 0 5px rgba(0, 0, 0, 0.5), + 2px 0 5px rgba(0, 0, 0, 0.8); + box-sizing: border-box; + padding: 18px 16px; + overflow-y: auto; +} + +#sidebar h2 { + margin: 8px 0 12px; + font-size: 1.25rem; + color: var(--ac-gold); + text-shadow: + 2px 2px 3px rgba(0, 0, 0, 0.8), + 0 0 10px rgba(212, 175, 55, 0.3); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; +} + +#playerList { + list-style: none; + margin: 0; + padding: 0; +} + +/* Filter input - AC style */ +.player-filter { + width: 100%; + padding: 6px 10px; + margin-bottom: 12px; + background: var(--ac-dark-stone); + color: var(--ac-gold); + border: 2px solid var(--ac-border-dark); + border-radius: 2px; + font-size: 0.9rem; + box-sizing: border-box; + box-shadow: + inset 2px 2px 3px rgba(0, 0, 0, 0.5), + inset -1px -1px 2px rgba(255, 255, 255, 0.05); + font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif; +} + +.player-filter:focus { + outline: none; + border-color: var(--ac-gold-dark); + box-shadow: + inset 2px 2px 3px rgba(0, 0, 0, 0.5), + 0 0 5px rgba(212, 175, 55, 0.5); +} + +/* Map container */ +#mapContainer { + flex: 1; + position: relative; + overflow: hidden; + background: var(--bg-main); + box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.8); +} + +/* Player list items - AC stone panels */ +#playerList li { + display: grid; + grid-template-columns: 1fr auto auto auto auto auto; + grid-template-rows: auto auto auto auto; + grid-template-areas: + "name name name name name name" + "kills totalkills kph kph kph kph" + "rares kpr meta meta meta meta" + "onlinetime deaths tapers tapers tapers tapers"; + gap: 4px 8px; + margin: 6px 0; + padding: 10px 12px; + background: linear-gradient(135deg, var(--ac-medium-stone) 0%, var(--ac-dark-stone) 100%); + border: 2px solid var(--ac-border-dark); + border-left: 4px solid var(--ac-gold-dark); + transition: all 0.2s; + box-shadow: + 1px 1px 3px rgba(0, 0, 0, 0.5), + inset 1px 1px 2px rgba(255, 255, 255, 0.05); +} + +/* Grid assignments */ +.player-name { + grid-area: name; + font-weight: 700; + color: var(--ac-gold); + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8); +} +.coordinates-inline { + font-size: 0.75rem; + color: var(--ac-text-dim); + font-weight: 400; + margin-left: 8px; +} + +.stat.kills { grid-area: kills; } +.stat.total-kills { grid-area: totalkills; } +.stat.kph { grid-area: kph; } +.stat.rares { grid-area: rares; } +.stat.kpr { grid-area: kpr; } +.stat.meta { grid-area: meta; } +.stat.onlinetime { grid-area: onlinetime; } +.stat.deaths { grid-area: deaths; } +.stat.tapers { grid-area: tapers; } + +/* Stat pills - AC style */ +#playerList li .stat { + background: linear-gradient(180deg, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.5) 100%); + padding: 4px 8px; + border-radius: 2px; + display: inline-block; + font-size: 0.75rem; + white-space: nowrap; + color: var(--ac-text); + border: 1px solid rgba(0, 0, 0, 0.5); + box-shadow: + 1px 1px 2px rgba(0, 0, 0, 0.3), + inset 1px 1px 1px rgba(255, 255, 255, 0.05); +} + +/* Icons & suffixes */ +.stat.kills::before { content: "⚔️ "; } +.stat.total-kills::before { content: "🏆 "; } +.stat.kph::after { content: " KPH"; font-size:0.7em; color: var(--ac-text-dim); } +.stat.rares::before { content: "💎 "; } +.stat.rares::after { content: " Rares"; font-size:0.7em; color: var(--ac-text-dim); } +.stat.kpr::before { content: "📊 "; } +.stat.kpr::after { content: " KPR"; font-size:0.7em; color: var(--ac-text-dim); } + +/* Metastate pills */ +#playerList li .stat.meta { + background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%); + color: var(--ac-black); + border-color: var(--ac-gold-dark); +} + +#playerList li .stat.meta.green { + background: linear-gradient(180deg, #4ade80 0%, #22c55e 100%); + color: var(--ac-black); + border-color: #16a34a; +} + +#playerList li .stat.meta.red { + background: linear-gradient(180deg, #f87171 0%, #ef4444 100%); + color: #fff; + border-color: #dc2626; +} + +/* Chat/Stats/Inventory buttons - AC style */ +.chat-btn, .stats-btn, .inventory-btn { + margin-top: 4px; + margin-right: 4px; + padding: 3px 8px; + background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%); + color: var(--ac-black); + border: 1px solid var(--ac-gold-dark); + border-radius: 2px; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.5px; + box-shadow: + 1px 1px 2px rgba(0, 0, 0, 0.5), + inset 1px 1px 1px rgba(255, 255, 255, 0.3); + transition: all 0.15s; +} + +.chat-btn:hover, .stats-btn:hover, .inventory-btn:hover { + background: linear-gradient(180deg, var(--ac-gold-bright) 0%, var(--ac-gold) 100%); + box-shadow: + 1px 1px 2px rgba(0, 0, 0, 0.5), + inset 1px 1px 1px rgba(255, 255, 255, 0.4), + 0 0 5px rgba(212, 175, 55, 0.5); +} + +/* Windows - AC stone panel style */ +.chat-window, .stats-window, .inventory-window { + position: absolute; + top: 10px; + left: calc(var(--sidebar-width) + 10px); + width: 760px; + height: 300px; + background: linear-gradient(135deg, var(--ac-medium-stone) 0%, var(--ac-dark-stone) 100%); + border: 3px solid var(--ac-border-dark); + display: flex; + flex-direction: column; + z-index: 10000; + box-shadow: + 0 5px 20px rgba(0, 0, 0, 0.8), + inset 2px 2px 3px rgba(255, 255, 255, 0.05); + border-radius: 2px; +} + +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 15px; + background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-medium-stone) 100%); + border-bottom: 2px solid var(--ac-border-dark); + color: var(--ac-gold); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8); + box-shadow: 0 2px 3px rgba(0, 0, 0, 0.5); +} + +.chat-close-btn { + background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%); + color: #fff; + border: 1px solid #991b1b; + border-radius: 2px; + padding: 2px 8px; + font-size: 1rem; + cursor: pointer; + font-weight: 700; + box-shadow: + 1px 1px 2px rgba(0, 0, 0, 0.5), + inset 1px 1px 1px rgba(255, 255, 255, 0.3); +} + +.chat-close-btn:hover { + background: linear-gradient(180deg, #f87171 0%, #ef4444 100%); + box-shadow: + 1px 1px 2px rgba(0, 0, 0, 0.5), + inset 1px 1px 1px rgba(255, 255, 255, 0.4), + 0 0 5px rgba(239, 68, 68, 0.5); +} + +.chat-messages { + flex: 1; + padding: 10px 15px; + overflow-y: auto; + background: rgba(0, 0, 0, 0.3); + color: var(--ac-green); + font-family: "Courier New", Courier, monospace; + font-size: 0.9rem; + line-height: 1.4; + box-shadow: inset 2px 2px 5px rgba(0, 0, 0, 0.5); +} + +.chat-messages::-webkit-scrollbar { + width: 10px; +} + +.chat-messages::-webkit-scrollbar-track { + background: var(--ac-dark-stone); + box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.5); +} + +.chat-messages::-webkit-scrollbar-thumb { + background: var(--ac-gold-dark); + border-radius: 2px; + box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5); +} + +.chat-form { + display: flex; + padding: 10px 15px; + background: var(--ac-dark-stone); + border-top: 2px solid var(--ac-border-dark); + box-shadow: 0 -2px 3px rgba(0, 0, 0, 0.5); +} + +.chat-input { + flex: 1; + padding: 6px 10px; + background: rgba(0, 0, 0, 0.5); + color: var(--ac-green); + border: 2px solid var(--ac-border-dark); + border-radius: 2px; + font-family: "Courier New", Courier, monospace; + font-size: 0.9rem; + box-shadow: inset 2px 2px 3px rgba(0, 0, 0, 0.5); +} + +.chat-input:focus { + outline: none; + border-color: var(--ac-gold-dark); + box-shadow: + inset 2px 2px 3px rgba(0, 0, 0, 0.5), + 0 0 5px rgba(212, 175, 55, 0.3); +} + +/* Map elements */ +#mapGroup { + transform-origin: top left; + position: relative; +} + +#map { + display: block; + user-select: none; + -webkit-user-drag: none; + filter: brightness(0.9) contrast(1.1); +} + +.dot { + position: absolute; + width: 10px; + height: 10px; + border-radius: 50%; + transform: translate(-50%, -50%); + pointer-events: auto; + cursor: pointer; + z-index: 10; + box-shadow: + 0 0 5px rgba(0, 0, 0, 0.8), + 0 0 10px currentColor; + border: 1px solid rgba(0, 0, 0, 0.5); +} + +.dot.highlight { + animation: pulse 2s infinite; + z-index: 20; +} + +@keyframes pulse { + 0% { box-shadow: 0 0 5px rgba(0, 0, 0, 0.8), 0 0 10px currentColor; } + 50% { box-shadow: 0 0 10px rgba(0, 0, 0, 0.8), 0 0 20px currentColor, 0 0 30px currentColor; } + 100% { box-shadow: 0 0 5px rgba(0, 0, 0, 0.8), 0 0 10px currentColor; } +} + +/* Tooltip - AC style */ +.tooltip { + position: absolute; + display: none; + background: linear-gradient(135deg, rgba(26, 26, 26, 0.95) 0%, rgba(10, 10, 10, 0.95) 100%); + color: var(--ac-gold); + padding: 6px 10px; + border: 2px solid var(--ac-gold-dark); + border-radius: 2px; + font-size: 0.8rem; + pointer-events: none; + white-space: nowrap; + z-index: 1000; + box-shadow: + 0 2px 10px rgba(0, 0, 0, 0.8), + inset 1px 1px 2px rgba(255, 255, 255, 0.1); + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8); +} + +/* Coordinate display - AC style */ +.coordinates { + position: absolute; + display: none; + background: linear-gradient(135deg, rgba(0, 50, 100, 0.95) 0%, rgba(0, 30, 60, 0.95) 100%); + color: var(--ac-cyan); + padding: 4px 8px; + border: 2px solid rgba(0, 100, 150, 0.8); + border-radius: 2px; + font-size: 0.75rem; + font-family: "Courier New", Courier, monospace; + font-weight: 700; + pointer-events: none; + white-space: nowrap; + z-index: 999; + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.8), + inset 1px 1px 2px rgba(255, 255, 255, 0.1); + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8); +} + +/* Hover states */ +#playerList li:hover { + background: linear-gradient(135deg, var(--ac-light-stone) 0%, var(--ac-medium-stone) 100%); + border-left-color: var(--ac-gold-bright); + box-shadow: + 2px 2px 5px rgba(0, 0, 0, 0.5), + inset 1px 1px 2px rgba(255, 255, 255, 0.1), + 0 0 10px rgba(212, 175, 55, 0.2); +} + +#playerList li.selected { + background: linear-gradient(135deg, var(--ac-gold-dark) 0%, var(--ac-medium-stone) 100%); + border-left-color: var(--ac-gold-bright); + box-shadow: + 2px 2px 5px rgba(0, 0, 0, 0.5), + inset 1px 1px 2px rgba(255, 255, 255, 0.2), + 0 0 15px rgba(212, 175, 55, 0.3); +} + +/* Trail paths */ +#trails { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + opacity: 0.7; +} + +.trail-path { + stroke-width: 2; + stroke-opacity: 0.8; + filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); +} + +/* Stats window specific */ +.stats-window { + height: auto; +} + +.stats-window .chat-messages { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-auto-rows: auto; + gap: 10px; + padding: 10px; + overflow: visible; + background: var(--ac-dark-stone); + color: var(--ac-text); +} + +.stats-window iframe { + width: 350px; + height: 200px; + border: 2px solid var(--ac-border-dark); + box-shadow: + inset 2px 2px 3px rgba(0, 0, 0, 0.5), + 1px 1px 2px rgba(0, 0, 0, 0.3); + background: var(--ac-black); +} + +/* Stats time controls - AC style */ +.stats-controls { + display: flex; + gap: 8px; + padding: 10px 15px; + background: var(--ac-medium-stone); + border-bottom: 2px solid var(--ac-border-dark); + box-shadow: 0 2px 3px rgba(0, 0, 0, 0.5); +} + +.time-range-btn { + padding: 6px 12px; + background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-medium-stone) 100%); + color: var(--ac-text-dim); + border: 1px solid var(--ac-border-dark); + border-radius: 2px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 0.5px; + box-shadow: + 1px 1px 2px rgba(0, 0, 0, 0.5), + inset 1px 1px 2px rgba(255, 255, 255, 0.1); +} + +.time-range-btn:hover { + background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-light-stone) 100%); + color: var(--ac-gold); + box-shadow: + 1px 1px 2px rgba(0, 0, 0, 0.5), + inset 1px 1px 2px rgba(255, 255, 255, 0.2), + 0 0 5px rgba(212, 175, 55, 0.2); +} + +.time-range-btn.active { + background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%); + color: var(--ac-black); + border-color: var(--ac-gold-dark); + font-weight: 700; + box-shadow: + 1px 1px 2px rgba(0, 0, 0, 0.5), + inset 1px 1px 2px rgba(255, 255, 255, 0.3), + 0 0 10px rgba(212, 175, 55, 0.4); +} + +/* Inventory window */ +.inventory-content { + flex: 1; + padding: 15px; + background: var(--ac-dark-stone); + color: var(--ac-text); + overflow-y: auto; + box-shadow: inset 2px 2px 5px rgba(0, 0, 0, 0.5); +} + +.inventory-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: 1.1rem; + color: var(--ac-text-dim); + font-style: italic; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8); +} + +.stat.onlinetime::before { content: "🕑 "; } +.stat.deaths::before { content: "💀 "; } +.stat.tapers::before { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + background-image: url('prismatic-taper-icon.png'); + background-size: contain; + background-repeat: no-repeat; + margin-right: 4px; + vertical-align: text-bottom; +} + +/* Disable text selection during drag */ +.noselect { + user-select: none !important; +} + +/* Custom scrollbar for sidebar */ +#sidebar::-webkit-scrollbar { + width: 12px; +} + +#sidebar::-webkit-scrollbar-track { + background: var(--ac-dark-stone); + box-shadow: inset 2px 2px 3px rgba(0, 0, 0, 0.5); +} + +#sidebar::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%); + border-radius: 2px; + border: 1px solid var(--ac-gold-dark); + box-shadow: + 1px 1px 2px rgba(0, 0, 0, 0.5), + inset 1px 1px 1px rgba(255, 255, 255, 0.3); +} + +#sidebar::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, var(--ac-gold-bright) 0%, var(--ac-gold) 100%); +} + +/* Map container special effects */ +#mapContainer.dragging { + cursor: move; +} + +/* Additional hover effects */ +.player-item { + position: relative; + overflow: hidden; +} + +.player-item::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent 0%, rgba(212, 175, 55, 0.2) 50%, transparent 100%); + transition: left 0.5s; +} + +.player-item:hover::before { + left: 100%; +} \ No newline at end of file diff --git a/static/style.css b/static/style.css index 6d29ebaa..4102e318 100644 --- a/static/style.css +++ b/static/style.css @@ -6,7 +6,7 @@ */ /* CSS Custom Properties for theme colors and sizing */ :root { - --sidebar-width: 280px; + --sidebar-width: 340px; --bg-main: #111; --bg-side: #1a1a1a; --card: #222; @@ -43,27 +43,68 @@ body { .sort-buttons { /* Container for sorting controls; uses flex layout to distribute buttons equally */ display: flex; - gap: 4px; + gap: 2px; margin: 12px 16px 8px; } .sort-buttons .btn { - /* Base styling for each sort button: color, padding, border */ + /* Compact styling for sort buttons to fit 6 options */ flex: 1; - padding: 6px 8px; - background: #222; - color: #eee; - border: 1px solid #555; - border-radius: 4px; + padding: 4px 6px; + background: #333; + color: #ccc; + border: 1px solid #666; + border-radius: 3px; text-align: center; cursor: pointer; user-select: none; - font-size: 0.9rem; + font-size: 0.75rem; + font-weight: 500; + transition: all 0.15s; + min-width: 0; + white-space: nowrap; + overflow: hidden; } +.sort-buttons .btn:hover { + background: #444; + color: #fff; + border-color: #777; +} + .sort-buttons .btn.active { /* Active sort button highlighted with accent color */ background: var(--accent); color: #111; border-color: var(--accent); + position: relative; +} + +.sort-buttons .btn.active:hover { + background: var(--accent); + color: #111; +} + +/* Sort direction indicators */ +.sort-buttons .btn.active::after { + content: ''; + position: absolute; + top: 2px; + right: 2px; + width: 0; + height: 0; + border-left: 3px solid transparent; + border-right: 3px solid transparent; +} + +/* Most sorts are descending (down arrow) */ +.sort-buttons .btn.active::after { + border-top: 4px solid #111; +} + +/* Name and KPR are ascending (up arrow) */ +.sort-buttons .btn.active[data-value="name"]::after, +.sort-buttons .btn.active[data-value="kpr"]::after { + border-top: none; + border-bottom: 4px solid #111; } /* ---------- sidebar --------------------------------------------- */ @@ -178,6 +219,22 @@ body { white-space: nowrap; z-index: 1000; } + +/* ---------- coordinate display ---------------------------------- */ +.coordinates { + position: absolute; + display: none; + background: rgba(0, 50, 100, 0.9); + color: #fff; + padding: 3px 6px; + border-radius: 3px; + font-size: 0.75rem; + font-family: monospace; + pointer-events: none; + white-space: nowrap; + z-index: 999; + border: 1px solid rgba(100, 150, 200, 0.5); +} /* make each row a flex container */ /* 2-column flex layout for each player row */ /* make each row a flex container */ @@ -185,13 +242,13 @@ body { /* make each player row into a 3×2 grid */ #playerList li { display: grid; - grid-template-columns: 1fr auto; + grid-template-columns: 1fr auto auto auto auto auto; grid-template-rows: auto auto auto auto; grid-template-areas: - "name loc" - "kills kph" - "rares meta" - "onlinetime deaths"; + "name name name name name name" + "kills totalkills kph kph kph kph" + "rares kpr meta meta meta meta" + "onlinetime deaths tapers tapers tapers tapers"; gap: 4px 8px; margin: 6px 0; padding: 8px 10px; @@ -203,14 +260,17 @@ body { /* assign each span into its grid cell */ .player-name { grid-area: name; font-weight: 600; color: var(--text); } -.player-loc { grid-area: loc; font-size: 0.75rem; color: #aaa; } +.coordinates-inline { font-size: 0.75rem; color: #aaa; font-weight: 400; margin-left: 8px; } .stat.kills { grid-area: kills; } +.stat.total-kills { grid-area: totalkills; } .stat.kph { grid-area: kph; } .stat.rares { grid-area: rares; } +.stat.kpr { grid-area: kpr; } .stat.meta { grid-area: meta; } .stat.onlinetime { grid-area: onlinetime; } .stat.deaths { grid-area: deaths; } +.stat.tapers { grid-area: tapers; } /* pill styling */ #playerList li .stat { @@ -224,9 +284,13 @@ body { } /* icons & suffixes */ -.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; } +.stat.kills::before { content: "⚔️ "; } +.stat.total-kills::before { content: "🏆 "; } +.stat.kph::after { content: " KPH"; font-size:0.7em; color:#aaa; } +.stat.rares::before { content: "💎 "; } +.stat.rares::after { content: " Rares"; font-size:0.7em; color:#aaa; } +.stat.kpr::before { content: "📊 "; } +.stat.kpr::after { content: " KPR"; font-size:0.7em; color:#aaa; } /* metastate pill colors are assigned dynamically: green for “good” states, red otherwise */ #playerList li .stat.meta { /* fallback */ @@ -245,7 +309,7 @@ body { } /* ---------- chat window styling ------------------------------- */ -.chat-btn, .stats-btn { +.chat-btn, .stats-btn, .inventory-btn { margin-top: 4px; padding: 2px 6px; background: var(--accent); @@ -256,7 +320,7 @@ body { cursor: pointer; } -.chat-window, .stats-window { +.chat-window, .stats-window, .inventory-window { position: absolute; top: 10px; /* position window to start just right of the sidebar */ @@ -326,6 +390,17 @@ body.noselect, body.noselect * { } .stat.onlinetime::before { content: "🕑 "} .stat.deaths::before { content: "💀 "} +.stat.tapers::before { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + background-image: url('prismatic-taper-icon.png'); + background-size: contain; + background-repeat: no-repeat; + margin-right: 4px; + vertical-align: text-bottom; +} /* hover & selected states */ #playerList li:hover { background: var(--card-hov); } @@ -365,3 +440,53 @@ body.noselect, body.noselect * { height: 200px; border: none; } + +/* ---------- stats window time controls --------------------------- */ +.stats-controls { + display: flex; + gap: 8px; + padding: 10px 15px; + background: #333; + border-bottom: 1px solid #555; +} + +.time-range-btn { + padding: 6px 12px; + background: #444; + color: #ccc; + border: 1px solid #666; + border-radius: 4px; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s; +} + +.time-range-btn:hover { + background: #555; + color: #fff; +} + +.time-range-btn.active { + background: var(--accent); + color: #111; + border-color: var(--accent); +} + +/* ---------- inventory window styling ----------------------------- */ +.inventory-content { + flex: 1; + padding: 15px; + background: var(--card); + color: var(--text); + overflow-y: auto; +} + +.inventory-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: 1.1rem; + color: #888; + font-style: italic; +}