Compare commits

...

14 commits

Author SHA1 Message Date
erik
7050cfb8b7 feat: add missing legendary cantrips to suitbuilder
Add Dual Wield, Recklessness, Deception, Sneak Attack, and Dirty Fighting cantrips to both the HTML checkbox UI and the COMMON_CANTRIPS JS array for lock form dropdowns.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-04 16:16:52 +00:00
erik
973c3722bc fix: address code review findings for inventory delta feature
- Fix remaining f-string SQL injection in process_inventory (same pattern
  as single-item endpoints: parameterized ANY(:ids) queries)
- Add null guard for item_id in backend delta remove handler
- Add response status logging for inventory service HTTP calls
- Fix frontend ID fallback consistency in updateInventoryLive
- Replace debug print() with logger.debug()
- Add comment for Decal Slot_Decal magic number

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:58:10 +00:00
erik
f145e6e131 feat: fix inventory service SQL injection, add slot population, and live frontend updates
- Replace f-string SQL interpolation with parameterized ANY(:ids) queries
- Populate slot column from IntValues[231735296] (Decal Slot key)
- Add startup migration to add container_id/slot columns to existing DB
- Extract createInventorySlot() for reuse by initial load and live deltas
- Add updateInventoryLive() handler for WebSocket inventory_delta messages
- Add inventory_delta case to browser WebSocket message dispatcher

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:51:20 +00:00
erik
749652d534 feat: add single-item upsert/delete endpoints and container/slot columns
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:43:58 +00:00
erik
664bd50388 feat: handle inventory_delta messages and broadcast to browsers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:41:02 +00:00
erik
176fb020ec Redesign character window to match TreeStats layout and style
Replace the AC stone-themed single-scroll character window with a TreeStats-
style tabbed interface. Two side-by-side tab containers: left (Attributes,
Skills, Titles) and right (Augmentations, Ratings, Other), plus an Allegiance
section below. Exact TreeStats color palette (#000022 bg, #af7a30 gold
borders, purple specialized, teal trained). Backend accepts new properties
and titles fields in character_stats message for JSONB storage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:39:20 +00:00
erik
45cedd0ec9 Add plugin character stats streaming implementation plan
4-task plan covering WebSocket send method, CharacterStats.cs data
collection class, PluginCore wiring (ServerDispatch, timer, login),
and end-to-end testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:32:41 +00:00
erik
9c91ed0afb Add plugin character stats streaming design document
Design for adding character_stats event to MosswartMassacre plugin,
covering data collection from CharacterFilter API, network message
interception for allegiance/luminance, and 10-minute timer send.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:30:37 +00:00
erik
10bb6c30dd Add AC game UI replica styling for character stats window
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:12:45 +00:00
erik
a545a8b920 Add full character window with live stats, vitals, skills, and allegiance display
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:10:48 +00:00
erik
e71dfb4ec3 Add Char button to player list with stub character window
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:07:37 +00:00
erik
ab9f86d7a6 Add character_stats table for persistent character data storage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:59:49 +00:00
erik
a824451365 Add character stats window implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:57:07 +00:00
erik
7d52ac2fe4 Add character stats window design document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:52:32 +00:00
12 changed files with 3708 additions and 154 deletions

View file

@ -9,6 +9,7 @@ from datetime import datetime, timedelta, timezone
from databases import Database
from sqlalchemy import MetaData, Table, Column, Integer, String, Float, DateTime, text
from sqlalchemy import Index, BigInteger, JSON, Boolean, UniqueConstraint
from sqlalchemy.sql import func
# Environment: Postgres/TimescaleDB connection URL
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth")
@ -175,6 +176,20 @@ Index(
server_health_checks.c.timestamp.desc()
)
character_stats = Table(
"character_stats",
metadata,
Column("character_name", String, primary_key=True, nullable=False),
Column("timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("level", Integer, nullable=True),
Column("total_xp", BigInteger, nullable=True),
Column("unassigned_xp", BigInteger, nullable=True),
Column("luminance_earned", BigInteger, nullable=True),
Column("luminance_total", BigInteger, nullable=True),
Column("deaths", Integer, nullable=True),
Column("stats_data", JSON, nullable=False),
)
async def init_db_async():
"""Initialize PostgreSQL/TimescaleDB schema and hypertable.
@ -250,6 +265,26 @@ async def init_db_async():
except Exception as e:
print(f"Warning: failed to create portal table constraints: {e}")
# Ensure character_stats table exists with JSONB column type
try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS character_stats (
character_name VARCHAR(255) PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
level INTEGER,
total_xp BIGINT,
unassigned_xp BIGINT,
luminance_earned BIGINT,
luminance_total BIGINT,
deaths INTEGER,
stats_data JSONB NOT NULL
)
"""))
print("character_stats table created/verified successfully")
except Exception as e:
print(f"Warning: failed to create character_stats table: {e}")
async def cleanup_old_portals():
"""Clean up portals older than 1 hour."""
try:

View file

@ -0,0 +1,308 @@
# Character Stats Window - Design Document
## Overview
Add a live character stats window to the Dereth Tracker map interface, styled as an Asheron's Call game UI replica. Accessible via a "Char" button on each player in the list, alongside the existing Chat, Stats, and Inventory buttons.
**Scope:** MosswartOverlord only (database, backend, frontend). The plugin implementation is a separate follow-up with a handoff spec.
---
## Architecture: Single Event + JSONB Table with Indexed Columns
One new `character_stats` event type from the plugin. Backend stores in a single `character_stats` table with key columns extracted for efficient SQL queries (level, XP, luminance) plus a `stats_data` JSONB column for the full payload. In-memory cache for live display, DB for persistence.
---
## Database Schema
New table `character_stats`:
```sql
CREATE TABLE character_stats (
character_name VARCHAR(255) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
level INTEGER,
total_xp BIGINT,
unassigned_xp BIGINT,
luminance_earned BIGINT,
luminance_total BIGINT,
deaths INTEGER,
stats_data JSONB NOT NULL,
PRIMARY KEY (character_name)
);
```
Single row per character, upserted on each 10-minute update.
### JSONB `stats_data` Structure
```json
{
"attributes": {
"strength": {"base": 290, "creation": 100},
"endurance": {"base": 200, "creation": 100},
"coordination": {"base": 240, "creation": 100},
"quickness": {"base": 220, "creation": 10},
"focus": {"base": 250, "creation": 100},
"self": {"base": 200, "creation": 100}
},
"vitals": {
"health": {"base": 341},
"stamina": {"base": 400},
"mana": {"base": 300}
},
"skills": {
"war_magic": {"base": 533, "training": "Specialized"},
"melee_defense": {"base": 488, "training": "Specialized"},
"life_magic": {"base": 440, "training": "Trained"},
"arcane_lore": {"base": 10, "training": "Untrained"}
},
"allegiance": {
"name": "Knights of Dereth",
"monarch": {"name": "KingName", "race": 1, "rank": 0, "gender": 0},
"patron": {"name": "PatronName", "race": 2, "rank": 5, "gender": 1},
"rank": 10,
"followers": 5
},
"race": "Aluvian",
"gender": "Male",
"birth": "2018-03-15 14:22:33",
"current_title": 42,
"skill_credits": 0
}
```
---
## Backend
### WebSocket Handler (`main.py`)
New `character_stats` event type in the `/ws/position` handler. Same pattern as vitals:
1. **Validate** with `CharacterStatsMessage` Pydantic model
2. **Cache** in `live_character_stats: Dict[str, dict]` for instant access
3. **Persist** to `character_stats` table via upsert (`INSERT ... ON CONFLICT (character_name) DO UPDATE`)
4. **Broadcast** to browser clients via `_broadcast_to_browser_clients()`
### Pydantic Model
```python
class CharacterStatsMessage(BaseModel):
character_name: str
timestamp: datetime
level: Optional[int]
total_xp: Optional[int]
unassigned_xp: Optional[int]
luminance_earned: Optional[int]
luminance_total: Optional[int]
deaths: Optional[int]
race: Optional[str]
gender: Optional[str]
birth: Optional[str]
current_title: Optional[int]
skill_credits: Optional[int]
attributes: Optional[dict]
vitals: Optional[dict]
skills: Optional[dict]
allegiance: Optional[dict]
```
### HTTP Endpoint
```
GET /api/character-stats/{name}
```
Returns latest stats for a character. Checks in-memory cache first, falls back to DB. Used when a browser opens a character window after the initial broadcast.
### Test Endpoint (temporary, for development)
```
POST /api/character-stats/test
```
Accepts a mock `character_stats` payload, processes it through the same pipeline (cache + DB + broadcast). Allows full end-to-end testing without the plugin running.
---
## Frontend
### Character Button
New "Char" button in the player list, same pattern as Chat/Stats/Inventory:
```javascript
const charBtn = document.createElement('button');
charBtn.className = 'char-btn';
charBtn.textContent = 'Char';
// click -> showCharacterWindow(playerData.character_name)
```
### `showCharacterWindow(name)`
Uses existing `createWindow` helper. Window size: 450x650px (tall and narrow like the game panel).
**Data loading:**
1. On open, fetch `GET /api/character-stats/{name}`
2. Listen for `character_stats` WebSocket broadcasts to update live
3. Vitals bars update from existing `vitals` WebSocket messages (5-second stream)
4. If no data exists, show "Awaiting character data..." placeholder
### Window Layout
Stacked vertically, mimicking the AC character panel:
1. **Header** - Character name, level, race/gender, title. Gold text on dark background.
2. **Attributes panel** - 3x2 grid:
```
Strength 290 Quickness 220
Endurance 200 Focus 250
Coordination 240 Self 200
```
Base values shown, creation values in smaller text.
3. **Vitals bars** - Red (HP), yellow (Stamina), blue (Mana) bars with current/max numbers. Live-updating from existing vitals stream.
4. **Skills section** - Scrollable, grouped by training level:
- **Specialized** (gold text)
- **Trained** (white text)
- **Untrained** (grey text)
Each shows skill name + level.
5. **Allegiance section** - Monarch, patron, rank, followers count.
6. **Footer** - XP, unassigned XP, luminance, deaths, birth date.
---
## Styling: AC Game UI Replica
Color palette drawn from the Asheron's Call interface:
```css
--ac-bg: #1a1410; /* Dark brown/black background */
--ac-panel: #2a2218; /* Panel background */
--ac-border: #8b7355; /* Gold/brown borders */
--ac-header: #d4a843; /* Gold header text */
--ac-text: #c8b89a; /* Parchment-colored body text */
--ac-text-dim: #7a6e5e; /* Dimmed/secondary text */
--ac-specialized: #d4a843; /* Gold for specialized skills */
--ac-trained: #c8b89a; /* Light for trained */
--ac-untrained: #5a5248; /* Grey for untrained */
```
Vitals bar colors:
- Health: `#8b1a1a` bg, `#cc3333` fill (red)
- Stamina: `#8b7a1a` bg, `#ccaa33` fill (yellow)
- Mana: `#1a3a8b` bg, `#3366cc` fill (blue)
Panel styling:
- Subtle inner border with gold/brown
- CSS gradient background to simulate parchment grain (no image files)
- Section dividers as thin gold lines
- Skill rows with subtle hover highlight
- Compact padding (information-dense like the game UI)
---
## Plugin Event Contract
The plugin will send a `character_stats` message via the existing WebSocket connection:
- **Frequency:** On login + every 10 minutes
- **Channel:** Existing `/ws/position` WebSocket
```json
{
"type": "character_stats",
"timestamp": "2026-02-26T12:34:56Z",
"character_name": "Barris",
"level": 275,
"race": "Aluvian",
"gender": "Male",
"birth": "2018-03-15 14:22:33",
"total_xp": 191226310247,
"unassigned_xp": 0,
"skill_credits": 0,
"luminance_earned": 500000,
"luminance_total": 1500000,
"deaths": 3175,
"current_title": 42,
"attributes": {
"strength": {"base": 290, "creation": 100},
"endurance": {"base": 200, "creation": 100},
"coordination": {"base": 240, "creation": 100},
"quickness": {"base": 220, "creation": 10},
"focus": {"base": 250, "creation": 100},
"self": {"base": 200, "creation": 100}
},
"vitals": {
"health": {"base": 341},
"stamina": {"base": 400},
"mana": {"base": 300}
},
"skills": {
"war_magic": {"base": 533, "training": "Specialized"},
"melee_defense": {"base": 488, "training": "Specialized"},
"life_magic": {"base": 440, "training": "Trained"},
"arcane_lore": {"base": 10, "training": "Untrained"}
},
"allegiance": {
"name": "Knights of Dereth",
"monarch": {"name": "KingName", "race": 1, "rank": 0, "gender": 0},
"patron": {"name": "PatronName", "race": 2, "rank": 5, "gender": 1},
"rank": 10,
"followers": 5
}
}
```
---
## Data Flow
```
Plugin (every 10 min + on login)
│ character_stats JSON via /ws/position
Backend handler
│ Pydantic validation
├──▶ live_character_stats cache (in-memory)
├──▶ character_stats table (upsert)
└──▶ _broadcast_to_browser_clients()
/ws/live → Browser
│ message.type === 'character_stats'
Character window updates live
Browser can also fetch on demand:
GET /api/character-stats/{name} → cache → DB fallback
```
Vitals (HP/Stam/Mana) update separately via existing 5-second vitals stream.
---
## Files Modified
| File | Changes |
|------|---------|
| `main.py` | New `character_stats` handler, Pydantic model, in-memory cache, HTTP endpoint, test endpoint |
| `db_async.py` | New `character_stats` table definition |
| `static/script.js` | New "Char" button, `showCharacterWindow()`, WebSocket listener for `character_stats` |
| `static/style.css` | AC-themed character window styles |
---
## What's NOT in Scope
- Plugin implementation (separate follow-up with handoff spec)
- Historical stat tracking over time (table supports it but no UI yet)
- Skill icons from the game (text-only for v1)
- Title name resolution (show title ID, not name)
- Vassal list display (just monarch/patron/rank/followers)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,201 @@
# Plugin Character Stats Streaming - Design Document
## Overview
Add character stats streaming to the MosswartMassacre Decal plugin. Sends a `character_stats` JSON payload via the existing WebSocket connection to MosswartOverlord on login and every 10 minutes.
**Scope:** MosswartMassacre plugin only. The backend (MosswartOverlord) already handles this event type.
---
## Architecture
A new `CharacterStats` class collects data from three sources and sends it via the existing WebSocket:
1. **CharacterFilter API** — level, XP, deaths, race, gender, birth, attributes, vitals, skills
2. **Network message interception** — allegiance (event 0x0020), luminance & title (event 0x0013)
3. **Separate 10-minute timer** — triggers the send, plus an immediate send on login
Uses Newtonsoft.Json with anonymous objects (same as existing `SendVitalsAsync`, `SendChatTextAsync`).
---
## Data Sources
| Data | Decal API |
|------|-----------|
| Level | `CharacterFilter.Level` |
| Deaths | `CharacterFilter.Deaths` |
| Skill Credits | `CharacterFilter.SkillPoints` |
| Total XP | `CharacterFilter.TotalXP` (Int64) |
| Unassigned XP | `CharacterFilter.UnassignedXP` (Int64) |
| Race | `CharacterFilter.Race` (string) |
| Gender | `CharacterFilter.Gender` (string) |
| Birth | `CharacterFilter.Birth` (DateTime) |
| Attributes (6) | `CharacterFilter.Attributes` collection — `.Name`, `.Base`, `.Creation` |
| Vitals (3) base | `CharacterFilter.Vitals` collection — `.Name`, `.Base` |
| Skills (all) | `CharacterFilter.Underlying.get_Skill((eSkillID)fs.SkillTable[i].Id)``.Name`, `.Base`, `.Training` |
| Allegiance | `EchoFilter.ServerDispatch` → game event 0x0020 message fields |
| Luminance | `EchoFilter.ServerDispatch` → game event 0x0013 QWORD keys 6, 7 |
| Current Title | `EchoFilter.ServerDispatch` → game event 0x0029 or 0x002b |
### Skill Access Pattern (from TreeStats reference)
Skills require COM interop with careful cleanup:
```csharp
Decal.Filters.FileService fs = CoreManager.Current.FileService as Decal.Filters.FileService;
Decal.Interop.Filters.SkillInfo skillinfo = null;
for (int i = 0; i < fs.SkillTable.Length; ++i)
{
try
{
skillinfo = CoreManager.Current.CharacterFilter.Underlying.get_Skill(
(Decal.Interop.Filters.eSkillID)fs.SkillTable[i].Id);
string name = skillinfo.Name.ToLower().Replace(" ", "_");
string training = skillinfo.Training.ToString().Substring(6); // Strip "eTrain" prefix
int baseValue = skillinfo.Base;
}
finally
{
if (skillinfo != null)
{
System.Runtime.InteropServices.Marshal.ReleaseComObject(skillinfo);
skillinfo = null;
}
}
}
```
### Allegiance Message Processing (from TreeStats reference)
Game event 0x0020 contains allegiance tree:
```csharp
void ProcessAllegianceInfo(NetworkMessageEventArgs e)
{
allegianceName = e.Message.Value<string>("allegianceName");
allegianceSize = e.Message.Value<Int32>("allegianceSize");
followers = e.Message.Value<Int32>("followers");
MessageStruct records = e.Message.Struct("records");
// Walk tree to find monarch (treeParent == 0) and patron (parent of current char)
}
```
### Luminance from Character Properties (from TreeStats reference)
Game event 0x0013 contains QWORD properties:
```csharp
// QWORD key 6 = AvailableLuminance (luminance_earned)
// QWORD key 7 = MaximumLuminance (luminance_total)
```
---
## Data Flow
```
Login Complete
├──▶ Hook EchoFilter.ServerDispatch (allegiance + luminance/title)
├──▶ Start 10-minute characterStatsTimer
└──▶ Send first stats after 5-second delay (let CharacterFilter populate)
Every 10 minutes (+ on login):
CharacterStats.CollectAndSend()
├── CharacterFilter: level, XP, deaths, race, attributes, vitals
├── FileService + get_Skill(): all skills with training levels
├── Cached network data: allegiance, luminance, title
├── Build anonymous object with Newtonsoft.Json
└── WebSocket.SendCharacterStatsAsync(payload)
└── Existing WebSocket → /ws/position → MosswartOverlord
Network Messages (event-driven, cached for next stats send):
0x0020 → allegiance name, monarch, patron, rank, followers
0x0013 → luminance_earned, luminance_total
0x0029 → current_title
```
---
## JSON Payload
Matches the MosswartOverlord backend contract:
```json
{
"type": "character_stats",
"timestamp": "2026-02-26T12:34:56Z",
"character_name": "Barris",
"level": 275,
"race": "Aluvian",
"gender": "Male",
"birth": "2018-03-15 14:22:33",
"total_xp": 191226310247,
"unassigned_xp": 0,
"skill_credits": 0,
"luminance_earned": 500000,
"luminance_total": 1500000,
"deaths": 3175,
"current_title": 42,
"attributes": {
"strength": {"base": 290, "creation": 100},
"endurance": {"base": 200, "creation": 100},
"coordination": {"base": 240, "creation": 100},
"quickness": {"base": 220, "creation": 10},
"focus": {"base": 250, "creation": 100},
"self": {"base": 200, "creation": 100}
},
"vitals": {
"health": {"base": 341},
"stamina": {"base": 400},
"mana": {"base": 300}
},
"skills": {
"war_magic": {"base": 533, "training": "Specialized"},
"melee_defense": {"base": 488, "training": "Specialized"},
"life_magic": {"base": 440, "training": "Trained"},
"arcane_lore": {"base": 10, "training": "Untrained"}
},
"allegiance": {
"name": "Knights of Dereth",
"monarch": {"name": "KingName", "race": 1, "rank": 0, "gender": 0},
"patron": {"name": "PatronName", "race": 2, "rank": 5, "gender": 1},
"rank": 10,
"followers": 5
}
}
```
---
## Files Modified
| File | Changes |
|------|---------|
| New: `CharacterStats.cs` | Data collection, network message processing, allegiance/luminance caching |
| `PluginCore.cs` | Hook `EchoFilter.ServerDispatch`, create 10-min timer, initial send on login, cleanup on shutdown |
| `WebSocket.cs` | Add `SendCharacterStatsAsync()` method |
---
## Implementation Details
- **Skill COM cleanup**: Every `SkillInfo` from `get_Skill()` must be released with `Marshal.ReleaseComObject()` in a `finally` block
- **Allegiance timing**: Network message 0x0020 arrives asynchronously after login. First stats send may have null allegiance; subsequent sends will include it
- **Login delay**: Wait 5 seconds after `LoginComplete` before first send to let CharacterFilter fully populate
- **Timer**: New `System.Timers.Timer` at 600,000ms (10 min), separate from vitals timer
- **Error handling**: Try/catch around entire collection — log errors, don't crash the plugin
- **Training string**: `skillinfo.Training.ToString()` returns values like `"eTrainSpecialized"` — strip first 6 chars to get `"Specialized"`
---
## What's NOT in Scope
- UI changes in the plugin (no new tab/view)
- Vassal list (just monarch + patron + rank + follower count)
- Buffed skill values (base only, matching TreeStats)
- Historical tracking (backend supports it, not a plugin concern)

View file

@ -0,0 +1,576 @@
# Plugin Character Stats Streaming - Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add character stats streaming to the MosswartMassacre Decal plugin, sending level, XP, attributes, vitals, skills, allegiance, luminance, and title data via WebSocket every 10 minutes.
**Architecture:** New `CharacterStats.cs` class handles data collection from Decal APIs and network message caching. PluginCore hooks `EchoFilter.ServerDispatch` for allegiance/luminance/title data, creates a 10-minute timer, and sends an initial update on login. WebSocket.cs gets one new send method.
**Tech Stack:** C# / .NET Framework, Decal Adapter API, Newtonsoft.Json, COM Interop for skill access
**Codebase:** `/home/erik/MosswartMassacre/` (spawn-detection branch)
**Reference:** TreeStats plugin at `/home/erik/treestats/Character.cs` for Decal API patterns
---
### Task 1: Add SendCharacterStatsAsync to WebSocket.cs
**Files:**
- Modify: `MosswartMassacre/WebSocket.cs:293-297`
**Step 1: Add the send method**
Add after `SendVitalsAsync` (line 297), following the exact same pattern:
```csharp
public static async Task SendCharacterStatsAsync(object statsData)
{
var json = JsonConvert.SerializeObject(statsData);
await SendEncodedAsync(json, CancellationToken.None);
}
```
**Step 2: Verify the file compiles**
Open the solution and verify no syntax errors. The method follows the identical pattern as `SendVitalsAsync` at line 293-297.
**Step 3: Commit**
```bash
cd /home/erik/MosswartMassacre
git add MosswartMassacre/WebSocket.cs
git commit -m "feat: add SendCharacterStatsAsync to WebSocket"
```
---
### Task 2: Create CharacterStats.cs - Data Structures and Network Message Handlers
This is the core data collection class. We split it into two tasks: this one covers the static data structures and network message processing, the next covers the collection and send logic.
**Files:**
- Create: `MosswartMassacre/CharacterStats.cs`
- Modify: `MosswartMassacre/MosswartMassacre.csproj:336` (add Compile Include)
**Step 1: Create CharacterStats.cs with data structures and message handlers**
Create `/home/erik/MosswartMassacre/MosswartMassacre/CharacterStats.cs`:
```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Runtime.InteropServices;
using Decal.Adapter;
using Decal.Adapter.Wrappers;
using Newtonsoft.Json;
namespace MosswartMassacre
{
public struct AllegianceInfoRecord
{
public string name;
public int rank;
public int race;
public int gender;
public AllegianceInfoRecord(string _name, int _rank, int _race, int _gender)
{
name = _name;
rank = _rank;
race = _race;
gender = _gender;
}
}
public static class CharacterStats
{
// Cached allegiance data (populated from network messages)
private static string allegianceName;
private static int allegianceSize;
private static int followers;
private static AllegianceInfoRecord monarch;
private static AllegianceInfoRecord patron;
private static int allegianceRank;
// Cached luminance data (populated from network messages)
private static long luminanceEarned = -1;
private static long luminanceTotal = -1;
// Cached title data (populated from network messages)
private static int currentTitle = -1;
/// <summary>
/// Reset all cached data. Call on plugin init.
/// </summary>
internal static void Init()
{
allegianceName = null;
allegianceSize = 0;
followers = 0;
monarch = new AllegianceInfoRecord();
patron = new AllegianceInfoRecord();
allegianceRank = 0;
luminanceEarned = -1;
luminanceTotal = -1;
currentTitle = -1;
}
/// <summary>
/// Process game event 0x0020 - Allegiance info.
/// Extracts monarch, patron, rank, followers from the allegiance tree.
/// Reference: TreeStats Character.cs:642-745
/// </summary>
internal static void ProcessAllegianceInfoMessage(NetworkMessageEventArgs e)
{
try
{
allegianceName = e.Message.Value<string>("allegianceName");
allegianceSize = e.Message.Value<Int32>("allegianceSize");
followers = e.Message.Value<Int32>("followers");
monarch = new AllegianceInfoRecord();
patron = new AllegianceInfoRecord();
MessageStruct records = e.Message.Struct("records");
int currentId = CoreManager.Current.CharacterFilter.Id;
var parentMap = new Dictionary<int, int>();
var recordMap = new Dictionary<int, AllegianceInfoRecord>();
for (int i = 0; i < records.Count; i++)
{
var record = records.Struct(i);
int charId = record.Value<int>("character");
int treeParent = record.Value<int>("treeParent");
parentMap[charId] = treeParent;
recordMap[charId] = new AllegianceInfoRecord(
record.Value<string>("name"),
record.Value<int>("rank"),
record.Value<int>("race"),
record.Value<int>("gender"));
// Monarch: treeParent <= 1
if (treeParent <= 1)
{
monarch = recordMap[charId];
}
}
// Patron: parent of current character
if (parentMap.ContainsKey(currentId) && recordMap.ContainsKey(parentMap[currentId]))
{
patron = recordMap[parentMap[currentId]];
}
// Our rank from the record
if (recordMap.ContainsKey(currentId))
{
allegianceRank = recordMap[currentId].rank;
}
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[CharStats] Allegiance processing error: {ex.Message}");
}
}
/// <summary>
/// Process game event 0x0013 - Character property data.
/// Extracts luminance from QWORD keys 6 and 7.
/// Reference: TreeStats Character.cs:582-640
/// </summary>
internal static void ProcessCharacterPropertyData(NetworkMessageEventArgs e)
{
try
{
MessageStruct props = e.Message.Struct("properties");
MessageStruct qwords = props.Struct("qwords");
for (int i = 0; i < qwords.Count; i++)
{
var tmpStruct = qwords.Struct(i);
long key = tmpStruct.Value<Int64>("key");
long value = tmpStruct.Value<Int64>("value");
if (key == 6) // AvailableLuminance
luminanceEarned = value;
else if (key == 7) // MaximumLuminance
luminanceTotal = value;
}
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[CharStats] Property processing error: {ex.Message}");
}
}
/// <summary>
/// Process game event 0x0029 - Titles list.
/// Extracts current title ID.
/// Reference: TreeStats Character.cs:551-580
/// </summary>
internal static void ProcessTitlesMessage(NetworkMessageEventArgs e)
{
try
{
currentTitle = e.Message.Value<Int32>("current");
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[CharStats] Title processing error: {ex.Message}");
}
}
/// <summary>
/// Process game event 0x002b - Set title (when player changes title).
/// </summary>
internal static void ProcessSetTitleMessage(NetworkMessageEventArgs e)
{
try
{
currentTitle = e.Message.Value<Int32>("title");
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[CharStats] Set title error: {ex.Message}");
}
}
/// <summary>
/// Collect all character data and send via WebSocket.
/// Called on login (after delay) and every 10 minutes.
/// </summary>
internal static void CollectAndSend()
{
if (!PluginCore.WebSocketEnabled)
return;
try
{
var cf = CoreManager.Current.CharacterFilter;
var culture = new CultureInfo("en-US");
// --- Attributes ---
var attributes = new Dictionary<string, object>();
foreach (var attr in cf.Attributes)
{
attributes[attr.Name.ToLower()] = new
{
@base = attr.Base,
creation = attr.Creation
};
}
// --- Vitals (base values) ---
var vitals = new Dictionary<string, object>();
foreach (var vital in cf.Vitals)
{
vitals[vital.Name.ToLower()] = new
{
@base = vital.Base
};
}
// --- Skills ---
var skills = new Dictionary<string, object>();
Decal.Filters.FileService fs = CoreManager.Current.FileService as Decal.Filters.FileService;
if (fs != null)
{
for (int i = 0; i < fs.SkillTable.Length; i++)
{
Decal.Interop.Filters.SkillInfo skillinfo = null;
try
{
skillinfo = cf.Underlying.get_Skill(
(Decal.Interop.Filters.eSkillID)fs.SkillTable[i].Id);
string name = skillinfo.Name.ToLower().Replace(" ", "_");
string training = skillinfo.Training.ToString();
// Training enum returns "eTrainSpecialized" etc, strip "eTrain" prefix
if (training.Length > 6)
training = training.Substring(6);
skills[name] = new
{
@base = skillinfo.Base,
training = training
};
}
finally
{
if (skillinfo != null)
{
Marshal.ReleaseComObject(skillinfo);
skillinfo = null;
}
}
}
}
// --- Allegiance ---
object allegiance = null;
if (allegianceName != null)
{
allegiance = new
{
name = allegianceName,
monarch = monarch.name != null ? new
{
name = monarch.name,
race = monarch.race,
rank = monarch.rank,
gender = monarch.gender
} : null,
patron = patron.name != null ? new
{
name = patron.name,
race = patron.race,
rank = patron.rank,
gender = patron.gender
} : null,
rank = allegianceRank,
followers = followers
};
}
// --- Build payload ---
var payload = new
{
type = "character_stats",
timestamp = DateTime.UtcNow.ToString("o"),
character_name = cf.Name,
level = cf.Level,
race = cf.Race,
gender = cf.Gender,
birth = cf.Birth.ToString(culture),
total_xp = cf.TotalXP,
unassigned_xp = cf.UnassignedXP,
skill_credits = cf.SkillPoints,
deaths = cf.Deaths,
luminance_earned = luminanceEarned >= 0 ? (long?)luminanceEarned : null,
luminance_total = luminanceTotal >= 0 ? (long?)luminanceTotal : null,
current_title = currentTitle >= 0 ? (int?)currentTitle : null,
attributes = attributes,
vitals = vitals,
skills = skills,
allegiance = allegiance
};
_ = WebSocket.SendCharacterStatsAsync(payload);
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[CharStats] Error collecting stats: {ex.Message}");
}
}
}
}
```
**Step 2: Add to .csproj**
In `MosswartMassacre/MosswartMassacre.csproj`, find line 336 (`<Compile Include="WebSocket.cs" />`) and add before it:
```xml
<Compile Include="CharacterStats.cs" />
```
**Step 3: Verify compilation**
Build the solution. All Decal APIs used here are the same ones already referenced by PluginCore.cs (CharacterFilter, FileService). The only new interop type is `Decal.Interop.Filters.SkillInfo` which comes from the existing Decal.Interop.Filters reference.
**Step 4: Commit**
```bash
cd /home/erik/MosswartMassacre
git add MosswartMassacre/CharacterStats.cs MosswartMassacre/MosswartMassacre.csproj
git commit -m "feat: add CharacterStats data collection and network message handlers"
```
---
### Task 3: Hook ServerDispatch and Timer in PluginCore.cs
Wire up the network message interception, 10-minute timer, and initial login send.
**Files:**
- Modify: `MosswartMassacre/PluginCore.cs`
**Step 1: Add the character stats timer field**
At line 66 (after `private static System.Windows.Forms.Timer commandTimer;`), add:
```csharp
private static Timer characterStatsTimer;
```
**Step 2: Hook EchoFilter.ServerDispatch in Startup()**
In `Startup()`, after line 184 (`CoreManager.Current.WorldFilter.ChangeObject += OnInventoryChange;`), add:
```csharp
// Subscribe to server messages for allegiance/luminance/title data
Core.EchoFilter.ServerDispatch += EchoFilter_ServerDispatch;
```
**Step 3: Initialize CharacterStats and timer in LoginComplete()**
In `CharacterFilter_LoginComplete()`, after the quest streaming initialization block (after line 404 `WriteToChat("[OK] Quest streaming initialized with full data refresh");`), add:
```csharp
// Initialize character stats streaming
try
{
CharacterStats.Init();
// Start 10-minute character stats timer
characterStatsTimer = new Timer(600000); // 10 minutes
characterStatsTimer.Elapsed += OnCharacterStatsUpdate;
characterStatsTimer.AutoReset = true;
characterStatsTimer.Start();
// Send initial stats after 5-second delay (let CharacterFilter populate)
var initialDelay = new Timer(5000);
initialDelay.AutoReset = false;
initialDelay.Elapsed += (s, args) =>
{
CharacterStats.CollectAndSend();
((Timer)s).Dispose();
};
initialDelay.Start();
WriteToChat("[OK] Character stats streaming initialized (10-min interval)");
}
catch (Exception ex)
{
WriteToChat($"[ERROR] Character stats initialization failed: {ex.Message}");
}
```
**Step 4: Add the timer handler and ServerDispatch handler**
After the `SendVitalsUpdate` method (after line 1162), add:
```csharp
private static void OnCharacterStatsUpdate(object sender, ElapsedEventArgs e)
{
try
{
CharacterStats.CollectAndSend();
}
catch (Exception ex)
{
WriteToChat($"[CharStats] Timer error: {ex.Message}");
}
}
private void EchoFilter_ServerDispatch(object sender, NetworkMessageEventArgs e)
{
try
{
if (e.Message.Type == 0xF7B0) // Game Event
{
int eventId = (int)e.Message["event"];
if (eventId == 0x0020) // Allegiance info
{
CharacterStats.ProcessAllegianceInfoMessage(e);
}
else if (eventId == 0x0013) // Login Character (properties)
{
CharacterStats.ProcessCharacterPropertyData(e);
}
else if (eventId == 0x0029) // Titles list
{
CharacterStats.ProcessTitlesMessage(e);
}
else if (eventId == 0x002b) // Set title
{
CharacterStats.ProcessSetTitleMessage(e);
}
}
}
catch (Exception ex)
{
WriteToChat($"[CharStats] ServerDispatch error: {ex.Message}");
}
}
```
**Step 5: Clean up in Shutdown()**
In `Shutdown()`, after the quest streaming timer cleanup (after line 285), add:
```csharp
// Stop and dispose character stats timer
if (characterStatsTimer != null)
{
characterStatsTimer.Stop();
characterStatsTimer.Elapsed -= OnCharacterStatsUpdate;
characterStatsTimer.Dispose();
characterStatsTimer = null;
}
```
Also in `Shutdown()`, after unsubscribing from inventory events (after line 253), add:
```csharp
// Unsubscribe from server dispatch
Core.EchoFilter.ServerDispatch -= EchoFilter_ServerDispatch;
```
**Step 6: Verify compilation**
Build the solution. All types used are already available: `NetworkMessageEventArgs` from `Decal.Adapter.Wrappers`, `Timer` from `System.Timers`.
**Step 7: Commit**
```bash
cd /home/erik/MosswartMassacre
git add MosswartMassacre/PluginCore.cs
git commit -m "feat: wire up character stats timer, ServerDispatch, and login send"
```
---
### Task 4: Build, Deploy, and Test End-to-End
**Step 1: Build the plugin**
Build the MosswartMassacre solution in Release mode. Copy the output DLL to the Decal plugin directory.
**Step 2: Test with a running game client**
1. Launch a game client with the plugin loaded
2. Watch for `[OK] Character stats streaming initialized (10-min interval)` in chat
3. After ~5 seconds, check MosswartOverlord logs for the initial character_stats message:
```bash
docker logs mosswartoverlord-dereth-tracker-1 2>&1 | grep "character_stats\|character stats" | tail -5
```
4. Open the web interface and click "Char" on the player that sent stats
5. Verify the character window shows real data (level, attributes, skills, etc.)
**Step 3: Verify allegiance data**
Allegiance info arrives via a separate network message. It may not be available on the first send but should appear on the 10-minute update. To force it sooner, open the allegiance panel in-game (which triggers the 0x0020 message).
**Step 4: Verify luminance data**
Luminance comes from the character property message (0x0013) which fires on login. Check that `luminance_earned` and `luminance_total` appear in the character window.
**Step 5: Wait for 10-minute update**
Leave the client running for 10+ minutes and verify a second stats update appears in logs. Verify the character window updates with any changed data.
---
## Files Summary
| File | Action | Description |
|------|--------|-------------|
| `MosswartMassacre/WebSocket.cs` | Modify | Add `SendCharacterStatsAsync()` |
| `MosswartMassacre/CharacterStats.cs` | Create | Data collection, network message handlers, `CollectAndSend()` |
| `MosswartMassacre/MosswartMassacre.csproj` | Modify | Add `<Compile Include="CharacterStats.cs" />` |
| `MosswartMassacre/PluginCore.cs` | Modify | Timer, ServerDispatch hook, login send, shutdown cleanup |

View file

@ -36,7 +36,11 @@ class Item(Base):
# Equipment status
current_wielded_location = Column(Integer, default=0, index=True) # 0 = not equipped
# Container/position tracking
container_id = Column(BigInteger, default=0) # Game container object ID (0 = character)
slot = Column(Integer, default=-1) # Slot position within container (-1 = unknown)
# Item state
bonded = Column(Integer, default=0) # 0=Normal, 1=Bonded, 2=Sticky, 4=Destroy on drop
attuned = Column(Integer, default=0) # 0=Normal, 1=Attuned

View file

@ -358,7 +358,19 @@ async def startup():
# Create tables if they don't exist
Base.metadata.create_all(engine)
# Migrate: add container_id and slot columns if missing (added for live inventory)
from sqlalchemy import inspect as sa_inspect
inspector = sa_inspect(engine)
existing_columns = {c['name'] for c in inspector.get_columns('items')}
with engine.begin() as conn:
if 'container_id' not in existing_columns:
conn.execute(sa.text("ALTER TABLE items ADD COLUMN container_id BIGINT DEFAULT 0"))
logger.info("Migration: added container_id column to items table")
if 'slot' not in existing_columns:
conn.execute(sa.text("ALTER TABLE items ADD COLUMN slot INTEGER DEFAULT -1"))
logger.info("Migration: added slot column to items table")
# Create performance indexes
create_indexes(engine)
@ -1345,16 +1357,15 @@ async def process_inventory(inventory: InventoryItem):
item_ids = await database.fetch_all(item_ids_query, {"character_name": inventory.character_name})
if item_ids:
id_list = [str(row['id']) for row in item_ids]
id_placeholder = ','.join(id_list)
db_ids = [row['id'] for row in item_ids]
# Delete from all related tables first
await database.execute(f"DELETE FROM item_raw_data WHERE item_id IN ({id_placeholder})")
await database.execute(f"DELETE FROM item_combat_stats WHERE item_id IN ({id_placeholder})")
await database.execute(f"DELETE FROM item_requirements WHERE item_id IN ({id_placeholder})")
await database.execute(f"DELETE FROM item_enhancements WHERE item_id IN ({id_placeholder})")
await database.execute(f"DELETE FROM item_ratings WHERE item_id IN ({id_placeholder})")
await database.execute(f"DELETE FROM item_spells WHERE item_id IN ({id_placeholder})")
for table in ('item_raw_data', 'item_combat_stats', 'item_requirements',
'item_enhancements', 'item_ratings', 'item_spells'):
await database.execute(
sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"),
{"ids": db_ids}
)
# Finally delete from main items table
await database.execute(
@ -1401,25 +1412,29 @@ async def process_inventory(inventory: InventoryItem):
burden=basic['burden'],
has_id_data=basic['has_id_data'],
last_id_time=item_data.get('LastIdTime', 0),
# Equipment status
current_wielded_location=basic['current_wielded_location'],
# Container/position tracking
container_id=item_data.get('ContainerId', 0),
slot=int(item_data.get('IntValues', {}).get('231735296', item_data.get('IntValues', {}).get(231735296, -1))), # Decal Slot_Decal key
# Item state
bonded=basic['bonded'],
attuned=basic['attuned'],
unique=basic['unique'],
# Stack/Container properties
stack_size=basic['stack_size'],
max_stack_size=basic['max_stack_size'],
items_capacity=basic['items_capacity'] if basic['items_capacity'] != -1 else None,
containers_capacity=basic['containers_capacity'] if basic['containers_capacity'] != -1 else None,
# Durability
structure=basic['structure'] if basic['structure'] != -1 else None,
max_structure=basic['max_structure'] if basic['max_structure'] != -1 else None,
# Special item flags
rare_id=basic['rare_id'] if basic['rare_id'] != -1 else None,
lifespan=basic['lifespan'] if basic['lifespan'] != -1 else None,
@ -1536,6 +1551,226 @@ async def process_inventory(inventory: InventoryItem):
errors=processing_errors if processing_errors else None
)
@app.post("/inventory/{character_name}/item",
summary="Upsert a single inventory item",
tags=["Data Processing"])
async def upsert_inventory_item(character_name: str, item: Dict[str, Any]):
"""Process and upsert a single item for a character's inventory."""
item_game_id = item.get('Id') or item.get('id')
if item_game_id is None:
raise HTTPException(status_code=400, detail="Item must have an 'Id' or 'id' field")
processed_count = 0
error_count = 0
async with database.transaction():
# Delete existing item with this character_name + item_id from all related tables
existing = await database.fetch_all(
"SELECT id FROM items WHERE character_name = :character_name AND item_id = :item_id",
{"character_name": character_name, "item_id": item_game_id}
)
if existing:
db_ids = [row['id'] for row in existing]
for table in ('item_raw_data', 'item_combat_stats', 'item_requirements',
'item_enhancements', 'item_ratings', 'item_spells'):
await database.execute(
sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"),
{"ids": db_ids}
)
await database.execute(
"DELETE FROM items WHERE character_name = :character_name AND item_id = :item_id",
{"character_name": character_name, "item_id": item_game_id}
)
# Process and insert the single item using the same logic as process_inventory
try:
properties = extract_item_properties(item)
basic = properties['basic']
timestamp = datetime.utcnow()
item_stmt = sa.insert(Item).values(
character_name=character_name,
item_id=item_game_id,
timestamp=timestamp,
name=basic['name'],
icon=basic['icon'],
object_class=basic['object_class'],
value=basic['value'],
burden=basic['burden'],
has_id_data=basic['has_id_data'],
last_id_time=item.get('LastIdTime', 0),
# Equipment status
current_wielded_location=basic['current_wielded_location'],
# Container/position tracking
container_id=item.get('ContainerId', 0),
slot=int(item.get('IntValues', {}).get('231735296', item.get('IntValues', {}).get(231735296, -1))),
# Item state
bonded=basic['bonded'],
attuned=basic['attuned'],
unique=basic['unique'],
# Stack/Container properties
stack_size=basic['stack_size'],
max_stack_size=basic['max_stack_size'],
items_capacity=basic['items_capacity'] if basic['items_capacity'] != -1 else None,
containers_capacity=basic['containers_capacity'] if basic['containers_capacity'] != -1 else None,
# Durability
structure=basic['structure'] if basic['structure'] != -1 else None,
max_structure=basic['max_structure'] if basic['max_structure'] != -1 else None,
# Special item flags
rare_id=basic['rare_id'] if basic['rare_id'] != -1 else None,
lifespan=basic['lifespan'] if basic['lifespan'] != -1 else None,
remaining_lifespan=basic['remaining_lifespan'] if basic['remaining_lifespan'] != -1 else None,
).returning(Item.id)
result = await database.fetch_one(item_stmt)
db_item_id = result['id']
# Store combat stats if applicable
combat = properties['combat']
if any(v != -1 and v != -1.0 for v in combat.values()):
combat_stmt = sa.dialects.postgresql.insert(ItemCombatStats).values(
item_id=db_item_id,
**{k: v if v != -1 and v != -1.0 else None for k, v in combat.items()}
).on_conflict_do_update(
index_elements=['item_id'],
set_=dict(**{k: v if v != -1 and v != -1.0 else None for k, v in combat.items()})
)
await database.execute(combat_stmt)
# Store requirements if applicable
requirements = properties['requirements']
if any(v not in [-1, None, ''] for v in requirements.values()):
req_stmt = sa.dialects.postgresql.insert(ItemRequirements).values(
item_id=db_item_id,
**{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()}
).on_conflict_do_update(
index_elements=['item_id'],
set_=dict(**{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()})
)
await database.execute(req_stmt)
# Store enhancements
enhancements = properties['enhancements']
enh_stmt = sa.dialects.postgresql.insert(ItemEnhancements).values(
item_id=db_item_id,
**{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()}
).on_conflict_do_update(
index_elements=['item_id'],
set_=dict(**{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()})
)
await database.execute(enh_stmt)
# Store ratings if applicable
ratings = properties['ratings']
if any(v not in [-1, -1.0, None] for v in ratings.values()):
rat_stmt = sa.dialects.postgresql.insert(ItemRatings).values(
item_id=db_item_id,
**{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()}
).on_conflict_do_update(
index_elements=['item_id'],
set_=dict(**{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()})
)
await database.execute(rat_stmt)
# Store spell data if applicable
spells = item.get('Spells', [])
active_spells = item.get('ActiveSpells', [])
all_spells = set(spells + active_spells)
if all_spells:
await database.execute(
"DELETE FROM item_spells WHERE item_id = :item_id",
{"item_id": db_item_id}
)
for spell_id in all_spells:
is_active = spell_id in active_spells
spell_stmt = sa.dialects.postgresql.insert(ItemSpells).values(
item_id=db_item_id,
spell_id=spell_id,
is_active=is_active
).on_conflict_do_nothing()
await database.execute(spell_stmt)
# Store raw data
raw_stmt = sa.dialects.postgresql.insert(ItemRawData).values(
item_id=db_item_id,
int_values=item.get('IntValues', {}),
double_values=item.get('DoubleValues', {}),
string_values=item.get('StringValues', {}),
bool_values=item.get('BoolValues', {}),
original_json=item
).on_conflict_do_update(
index_elements=['item_id'],
set_=dict(
int_values=item.get('IntValues', {}),
double_values=item.get('DoubleValues', {}),
string_values=item.get('StringValues', {}),
bool_values=item.get('BoolValues', {}),
original_json=item
)
)
await database.execute(raw_stmt)
processed_count = 1
except Exception as e:
error_msg = f"Error processing item {item_game_id}: {e}"
logger.error(error_msg)
error_count = 1
raise HTTPException(status_code=500, detail=error_msg)
logger.info(f"Single item upsert for {character_name}: item_id={item_game_id}, processed={processed_count}")
return {"status": "ok", "processed": processed_count}
@app.delete("/inventory/{character_name}/item/{item_id}",
summary="Delete a single inventory item",
tags=["Data Processing"])
async def delete_inventory_item(character_name: str, item_id: int):
"""Delete a single item from a character's inventory."""
deleted_count = 0
async with database.transaction():
# Find all DB rows for this character + game item_id
existing = await database.fetch_all(
"SELECT id FROM items WHERE character_name = :character_name AND item_id = :item_id",
{"character_name": character_name, "item_id": item_id}
)
if existing:
db_ids = [row['id'] for row in existing]
# Delete from all related tables first
for table in ('item_raw_data', 'item_combat_stats', 'item_requirements',
'item_enhancements', 'item_ratings', 'item_spells'):
await database.execute(
sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"),
{"ids": db_ids}
)
# Delete from main items table
await database.execute(
"DELETE FROM items WHERE character_name = :character_name AND item_id = :item_id",
{"character_name": character_name, "item_id": item_id}
)
deleted_count = len(existing)
logger.info(f"Single item delete for {character_name}: item_id={item_id}, deleted={deleted_count}")
return {"status": "ok", "deleted": deleted_count}
@app.get("/inventory/{character_name}",
summary="Get Character Inventory",
description="Retrieve processed inventory data for a specific character with normalized item properties.",
@ -3507,7 +3742,7 @@ async def get_available_items_by_slot(
# Debug: let's see how many items Barris actually has first
debug_query = f"SELECT COUNT(*) as total FROM items WHERE {char_filter}"
debug_result = await database.fetch_one(debug_query, query_params)
print(f"DEBUG: Total items for query: {debug_result['total']}")
logger.debug(f"Total items for query: {debug_result['total']}")
# Main query to get items with slot information
query = f"""

281
main.py
View file

@ -37,6 +37,7 @@ from db_async import (
spawn_events,
rare_events,
character_inventories,
character_stats,
portals,
server_health_checks,
server_status,
@ -778,6 +779,7 @@ app = FastAPI()
# In-memory store mapping character_name to the most recent telemetry snapshot
live_snapshots: Dict[str, dict] = {}
live_vitals: Dict[str, dict] = {}
live_character_stats: Dict[str, dict] = {}
# Shared secret used to authenticate plugin WebSocket connections (override for production)
SHARED_SECRET = "your_shared_secret"
@ -874,6 +876,33 @@ class VitalsMessage(BaseModel):
vitae: int
class CharacterStatsMessage(BaseModel):
"""
Model for the character_stats WebSocket message type.
Contains character attributes, skills, allegiance, and progression data.
Sent by plugin on login and every 10 minutes.
"""
character_name: str
timestamp: datetime
level: Optional[int] = None
total_xp: Optional[int] = None
unassigned_xp: Optional[int] = None
luminance_earned: Optional[int] = None
luminance_total: Optional[int] = None
deaths: Optional[int] = None
race: Optional[str] = None
gender: Optional[str] = None
birth: Optional[str] = None
current_title: Optional[int] = None
skill_credits: Optional[int] = None
attributes: Optional[dict] = None
vitals: Optional[dict] = None
skills: Optional[dict] = None
allegiance: Optional[dict] = None
properties: Optional[dict] = None # Dict[int, int] — DWORD properties (augs, ratings, etc.)
titles: Optional[list] = None # List[str] — character title names
@app.on_event("startup")
async def on_startup():
"""Event handler triggered when application starts up.
@ -1950,6 +1979,38 @@ async def ws_receive_snapshots(
except Exception as e:
logger.error(f"Failed to process inventory for {data.get('character_name', 'unknown')}: {e}", exc_info=True)
continue
# --- Inventory delta: single item add/remove/update ---
if msg_type == "inventory_delta":
try:
action = data.get("action")
char_name = data.get("character_name", "unknown")
if action == "remove":
item_id = data.get("item_id")
if item_id is not None:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.delete(
f"{INVENTORY_SERVICE_URL}/inventory/{char_name}/item/{item_id}"
)
if resp.status_code >= 400:
logger.warning(f"Inventory service returned {resp.status_code} for delta remove item_id={item_id}")
elif action in ("add", "update"):
item = data.get("item")
if item:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{INVENTORY_SERVICE_URL}/inventory/{char_name}/item",
json=item
)
if resp.status_code >= 400:
logger.warning(f"Inventory service returned {resp.status_code} for delta {action}")
# Broadcast delta to all browser clients
await _broadcast_to_browser_clients(data)
logger.debug(f"Inventory delta ({action}) for {char_name}")
except Exception as e:
logger.error(f"Failed to process inventory delta: {e}", exc_info=True)
continue
# --- Vitals message: store character health/stamina/mana and broadcast ---
if msg_type == "vitals":
payload = data.copy()
@ -1962,6 +2023,62 @@ async def ws_receive_snapshots(
except Exception as e:
logger.error(f"Failed to process vitals for {data.get('character_name', 'unknown')}: {e}", exc_info=True)
continue
# --- Character stats message: store character attributes/skills/progression and broadcast ---
if msg_type == "character_stats":
payload = data.copy()
payload.pop("type", None)
try:
stats_msg = CharacterStatsMessage.parse_obj(payload)
stats_dict = stats_msg.dict()
# Cache in memory
live_character_stats[stats_msg.character_name] = stats_dict
# Build stats_data JSONB (everything except extracted columns)
stats_data = {}
for key in ("attributes", "vitals", "skills", "allegiance",
"race", "gender", "birth", "current_title", "skill_credits",
"properties", "titles"):
if stats_dict.get(key) is not None:
stats_data[key] = stats_dict[key]
# Upsert to database
await database.execute(
"""
INSERT INTO character_stats
(character_name, timestamp, level, total_xp, unassigned_xp,
luminance_earned, luminance_total, deaths, stats_data)
VALUES
(:character_name, :timestamp, :level, :total_xp, :unassigned_xp,
:luminance_earned, :luminance_total, :deaths, :stats_data)
ON CONFLICT (character_name) DO UPDATE SET
timestamp = EXCLUDED.timestamp,
level = EXCLUDED.level,
total_xp = EXCLUDED.total_xp,
unassigned_xp = EXCLUDED.unassigned_xp,
luminance_earned = EXCLUDED.luminance_earned,
luminance_total = EXCLUDED.luminance_total,
deaths = EXCLUDED.deaths,
stats_data = EXCLUDED.stats_data
""",
{
"character_name": stats_msg.character_name,
"timestamp": stats_msg.timestamp,
"level": stats_msg.level,
"total_xp": stats_msg.total_xp,
"unassigned_xp": stats_msg.unassigned_xp,
"luminance_earned": stats_msg.luminance_earned,
"luminance_total": stats_msg.luminance_total,
"deaths": stats_msg.deaths,
"stats_data": json.dumps(stats_data),
})
# Broadcast to browser clients
await _broadcast_to_browser_clients(data)
logger.info(f"Updated character stats for {stats_msg.character_name}: Level {stats_msg.level}")
except Exception as e:
logger.error(f"Failed to process character_stats for {data.get('character_name', 'unknown')}: {e}", exc_info=True)
continue
# --- Quest message: update cache and broadcast (no database storage) ---
if msg_type == "quest":
character_name = data.get("character_name")
@ -2245,6 +2362,170 @@ async def get_stats(character_name: str):
logger.error(f"Failed to get stats for character {character_name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# --- Character Stats API -------------------------------------------
@app.post("/character-stats/test")
async def test_character_stats_default():
"""Inject mock character_stats data for frontend development."""
return await test_character_stats("TestCharacter")
@app.post("/character-stats/test/{name}")
async def test_character_stats(name: str):
"""Inject mock character_stats data for a specific character name.
Processes through the same pipeline as real plugin data."""
mock_data = {
"type": "character_stats",
"timestamp": datetime.utcnow().isoformat() + "Z",
"character_name": name,
"level": 275,
"race": "Aluvian",
"gender": "Male",
"birth": "2018-03-15 14:22:33",
"total_xp": 191226310247,
"unassigned_xp": 4500000,
"skill_credits": 2,
"luminance_earned": 500000,
"luminance_total": 1500000,
"deaths": 3175,
"current_title": 42,
"attributes": {
"strength": {"base": 290, "creation": 100},
"endurance": {"base": 200, "creation": 100},
"coordination": {"base": 240, "creation": 100},
"quickness": {"base": 220, "creation": 10},
"focus": {"base": 250, "creation": 100},
"self": {"base": 200, "creation": 100}
},
"vitals": {
"health": {"base": 341},
"stamina": {"base": 400},
"mana": {"base": 300}
},
"skills": {
"war_magic": {"base": 533, "training": "Specialized"},
"life_magic": {"base": 440, "training": "Specialized"},
"creature_enchantment": {"base": 430, "training": "Specialized"},
"item_enchantment": {"base": 420, "training": "Specialized"},
"void_magic": {"base": 510, "training": "Specialized"},
"melee_defense": {"base": 488, "training": "Specialized"},
"missile_defense": {"base": 470, "training": "Specialized"},
"magic_defense": {"base": 460, "training": "Specialized"},
"two_handed_combat": {"base": 420, "training": "Specialized"},
"heavy_weapons": {"base": 410, "training": "Specialized"},
"finesse_weapons": {"base": 400, "training": "Trained"},
"light_weapons": {"base": 390, "training": "Trained"},
"missile_weapons": {"base": 380, "training": "Trained"},
"shield": {"base": 350, "training": "Trained"},
"dual_wield": {"base": 340, "training": "Trained"},
"arcane_lore": {"base": 330, "training": "Trained"},
"mana_conversion": {"base": 320, "training": "Trained"},
"healing": {"base": 300, "training": "Trained"},
"lockpick": {"base": 280, "training": "Trained"},
"assess_creature": {"base": 10, "training": "Untrained"},
"assess_person": {"base": 10, "training": "Untrained"},
"deception": {"base": 10, "training": "Untrained"},
"leadership": {"base": 10, "training": "Untrained"},
"loyalty": {"base": 10, "training": "Untrained"},
"jump": {"base": 10, "training": "Untrained"},
"run": {"base": 10, "training": "Untrained"},
"salvaging": {"base": 10, "training": "Untrained"},
"cooking": {"base": 10, "training": "Untrained"},
"fletching": {"base": 10, "training": "Untrained"},
"alchemy": {"base": 10, "training": "Untrained"},
"sneak_attack": {"base": 10, "training": "Untrained"},
"dirty_fighting": {"base": 10, "training": "Untrained"},
"recklessness": {"base": 10, "training": "Untrained"},
"summoning": {"base": 10, "training": "Untrained"}
},
"allegiance": {
"name": "Knights of Dereth",
"monarch": {"name": "HighKing", "race": 1, "rank": 0, "gender": 0},
"patron": {"name": "SirLancelot", "race": 1, "rank": 5, "gender": 0},
"rank": 8,
"followers": 12
}
}
# Process through the same pipeline as real data
payload = mock_data.copy()
payload.pop("type", None)
try:
stats_msg = CharacterStatsMessage.parse_obj(payload)
stats_dict = stats_msg.dict()
live_character_stats[stats_msg.character_name] = stats_dict
stats_data = {}
for key in ("attributes", "vitals", "skills", "allegiance",
"race", "gender", "birth", "current_title", "skill_credits"):
if stats_dict.get(key) is not None:
stats_data[key] = stats_dict[key]
await database.execute(
"""
INSERT INTO character_stats
(character_name, timestamp, level, total_xp, unassigned_xp,
luminance_earned, luminance_total, deaths, stats_data)
VALUES
(:character_name, :timestamp, :level, :total_xp, :unassigned_xp,
:luminance_earned, :luminance_total, :deaths, :stats_data)
ON CONFLICT (character_name) DO UPDATE SET
timestamp = EXCLUDED.timestamp,
level = EXCLUDED.level,
total_xp = EXCLUDED.total_xp,
unassigned_xp = EXCLUDED.unassigned_xp,
luminance_earned = EXCLUDED.luminance_earned,
luminance_total = EXCLUDED.luminance_total,
deaths = EXCLUDED.deaths,
stats_data = EXCLUDED.stats_data
""",
{
"character_name": stats_msg.character_name,
"timestamp": stats_msg.timestamp,
"level": stats_msg.level,
"total_xp": stats_msg.total_xp,
"unassigned_xp": stats_msg.unassigned_xp,
"luminance_earned": stats_msg.luminance_earned,
"luminance_total": stats_msg.luminance_total,
"deaths": stats_msg.deaths,
"stats_data": json.dumps(stats_data),
})
await _broadcast_to_browser_clients(mock_data)
return {"status": "ok", "character_name": stats_msg.character_name}
except Exception as e:
logger.error(f"Test endpoint failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.get("/character-stats/{name}")
async def get_character_stats(name: str):
"""Return latest character stats. Checks in-memory cache first, falls back to DB."""
try:
# Try in-memory cache first
if name in live_character_stats:
return JSONResponse(content=jsonable_encoder(live_character_stats[name]))
# Fall back to database
row = await database.fetch_one(
"SELECT * FROM character_stats WHERE character_name = :name",
{"name": name}
)
if row:
result = dict(row._mapping)
# Parse stats_data back from JSONB
if isinstance(result.get("stats_data"), str):
result["stats_data"] = json.loads(result["stats_data"])
# Merge stats_data fields into top level for frontend compatibility
stats_data = result.pop("stats_data", {})
result.update(stats_data)
return JSONResponse(content=jsonable_encoder(result))
return JSONResponse(content={"error": "No stats available for this character"}, status_code=404)
except Exception as e:
logger.error(f"Failed to get character stats for {name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# -------------------- static frontend ---------------------------
# Custom icon handler that prioritizes clean icons over originals
from fastapi.responses import FileResponse

View file

@ -159,9 +159,21 @@ function createNewListItem() {
}
});
const charBtn = document.createElement('button');
charBtn.className = 'char-btn';
charBtn.textContent = 'Char';
charBtn.addEventListener('click', (e) => {
e.stopPropagation();
const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
if (playerData) {
showCharacterWindow(playerData.character_name);
}
});
buttonsContainer.appendChild(chatBtn);
buttonsContainer.appendChild(statsBtn);
buttonsContainer.appendChild(inventoryBtn);
buttonsContainer.appendChild(charBtn);
li.appendChild(buttonsContainer);
// Store references for easy access
@ -169,6 +181,7 @@ function createNewListItem() {
li.chatBtn = chatBtn;
li.statsBtn = statsBtn;
li.inventoryBtn = inventoryBtn;
li.charBtn = charBtn;
return li;
}
@ -880,6 +893,175 @@ function updateStatsTimeRange(content, name, timeRange) {
}
// Show or create an inventory window for a character
/**
* Create a single inventory slot DOM element from item data.
* Used by both initial inventory load and live delta updates.
*/
function createInventorySlot(item) {
const slot = document.createElement('div');
slot.className = 'inventory-slot';
slot.setAttribute('data-item-id', item.Id || item.id || item.item_id || 0);
// Create layered icon container
const iconContainer = document.createElement('div');
iconContainer.className = 'item-icon-composite';
// Get base icon ID with portal.dat offset
const iconRaw = item.icon || item.Icon || 0;
const baseIconId = (iconRaw + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
// Check for overlay and underlay from enhanced format or legacy format
let overlayIconId = null;
let underlayIconId = null;
// Enhanced format (inventory service) - check for proper icon overlay/underlay properties
if (item.icon_overlay_id && item.icon_overlay_id > 0) {
overlayIconId = (item.icon_overlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
if (item.icon_underlay_id && item.icon_underlay_id > 0) {
underlayIconId = (item.icon_underlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
// Fallback: Enhanced format (inventory service) - check spells object for decal info
if (!overlayIconId && !underlayIconId && item.spells && typeof item.spells === 'object') {
if (item.spells.spell_decal_218103838 && item.spells.spell_decal_218103838 > 100) {
overlayIconId = (item.spells.spell_decal_218103838 + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
if (item.spells.spell_decal_218103848 && item.spells.spell_decal_218103848 > 100) {
underlayIconId = (item.spells.spell_decal_218103848 + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
} else if (item.IntValues) {
// Raw delta format from plugin - IntValues directly on item
if (item.IntValues['218103849'] && item.IntValues['218103849'] > 100) {
overlayIconId = (item.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
if (item.IntValues['218103850'] && item.IntValues['218103850'] > 100) {
underlayIconId = (item.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
} else if (item.item_data) {
// Legacy format - parse item_data
try {
const itemData = typeof item.item_data === 'string' ? JSON.parse(item.item_data) : item.item_data;
if (itemData.IntValues) {
if (itemData.IntValues['218103849'] && itemData.IntValues['218103849'] > 100) {
overlayIconId = (itemData.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
if (itemData.IntValues['218103850'] && itemData.IntValues['218103850'] > 100) {
underlayIconId = (itemData.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
}
} catch (e) {
console.warn('Failed to parse item data for', item.name || item.Name);
}
}
// Create underlay (bottom layer)
if (underlayIconId) {
const underlayImg = document.createElement('img');
underlayImg.className = 'icon-underlay';
underlayImg.src = `/icons/${underlayIconId}.png`;
underlayImg.alt = 'underlay';
underlayImg.onerror = function() { this.style.display = 'none'; };
iconContainer.appendChild(underlayImg);
}
// Create base icon (middle layer)
const baseImg = document.createElement('img');
baseImg.className = 'icon-base';
baseImg.src = `/icons/${baseIconId}.png`;
baseImg.alt = item.name || item.Name || 'Unknown Item';
baseImg.onerror = function() { this.src = '/icons/06000133.png'; };
iconContainer.appendChild(baseImg);
// Create overlay (top layer)
if (overlayIconId) {
const overlayImg = document.createElement('img');
overlayImg.className = 'icon-overlay';
overlayImg.src = `/icons/${overlayIconId}.png`;
overlayImg.alt = 'overlay';
overlayImg.onerror = function() { this.style.display = 'none'; };
iconContainer.appendChild(overlayImg);
}
// Create tooltip data (handle both inventory-service format and raw plugin format)
const itemName = item.name || item.Name || 'Unknown Item';
slot.dataset.name = itemName;
slot.dataset.value = item.value || item.Value || 0;
slot.dataset.burden = item.burden || item.Burden || 0;
// Store enhanced data for tooltips
if (item.max_damage !== undefined || item.object_class_name !== undefined || item.spells !== undefined) {
const enhancedData = {};
const possibleProps = [
'max_damage', 'armor_level', 'damage_bonus', 'attack_bonus',
'wield_level', 'skill_level', 'lore_requirement', 'equip_skill', 'equip_skill_name',
'material', 'material_name', 'material_id', 'imbue', 'item_set', 'tinks',
'workmanship', 'workmanship_text', 'damage_rating', 'crit_rating',
'heal_boost_rating', 'has_id_data', 'object_class_name', 'spells',
'enhanced_properties', 'damage_range', 'damage_type', 'min_damage',
'speed_text', 'speed_value', 'mana_display', 'spellcraft', 'current_mana', 'max_mana',
'melee_defense_bonus', 'magic_defense_bonus', 'missile_defense_bonus',
'elemental_damage_vs_monsters', 'mana_conversion_bonus', 'icon_overlay_id', 'icon_underlay_id'
];
possibleProps.forEach(prop => {
if (item.hasOwnProperty(prop) && item[prop] !== undefined && item[prop] !== null) {
enhancedData[prop] = item[prop];
}
});
slot.dataset.enhancedData = JSON.stringify(enhancedData);
} else {
slot.dataset.enhancedData = JSON.stringify({});
}
// Add tooltip on hover
slot.addEventListener('mouseenter', e => showInventoryTooltip(e, slot));
slot.addEventListener('mousemove', e => showInventoryTooltip(e, slot));
slot.addEventListener('mouseleave', hideInventoryTooltip);
slot.appendChild(iconContainer);
return slot;
}
/**
* Handle live inventory delta updates from WebSocket.
* Updates the inventory grid for a character if their inventory window is open.
*/
function updateInventoryLive(delta) {
const name = delta.character_name;
const win = inventoryWindows[name];
if (!win) return; // No inventory window open for this character
const grid = win.querySelector('.inventory-grid');
if (!grid) return;
if (delta.action === 'remove') {
const itemId = delta.item_id || (delta.item && (delta.item.Id || delta.item.id));
const existing = grid.querySelector(`[data-item-id="${itemId}"]`);
if (existing) existing.remove();
} else if (delta.action === 'add') {
const newSlot = createInventorySlot(delta.item);
grid.appendChild(newSlot);
} else if (delta.action === 'update') {
const itemId = delta.item.Id || delta.item.id || delta.item.item_id;
const existing = grid.querySelector(`[data-item-id="${itemId}"]`);
if (existing) {
const newSlot = createInventorySlot(delta.item);
existing.replaceWith(newSlot);
} else {
const newSlot = createInventorySlot(delta.item);
grid.appendChild(newSlot);
}
}
// Update item count
const countEl = win.querySelector('.inventory-count');
if (countEl) {
const slotCount = grid.querySelectorAll('.inventory-slot').length;
countEl.textContent = `${slotCount} items`;
}
}
function showInventoryWindow(name) {
debugLog('showInventoryWindow called for:', name);
const windowId = `inventoryWindow-${name}`;
@ -924,139 +1106,7 @@ function showInventoryWindow(name) {
// Render each item
data.items.forEach(item => {
const slot = document.createElement('div');
slot.className = 'inventory-slot';
// Create layered icon container
const iconContainer = document.createElement('div');
iconContainer.className = 'item-icon-composite';
// Get base icon ID with portal.dat offset
const baseIconId = (item.icon + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
// Check for overlay and underlay from enhanced format or legacy format
let overlayIconId = null;
let underlayIconId = null;
// Enhanced format (inventory service) - check for proper icon overlay/underlay properties
if (item.icon_overlay_id && item.icon_overlay_id > 0) {
overlayIconId = (item.icon_overlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
if (item.icon_underlay_id && item.icon_underlay_id > 0) {
underlayIconId = (item.icon_underlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
// Fallback: Enhanced format (inventory service) - check spells object for decal info
if (!overlayIconId && !underlayIconId && item.spells && typeof item.spells === 'object') {
// Icon overlay (using the actual property names from the data)
// Only use valid icon IDs (must be > 100 to avoid invalid small IDs)
if (item.spells.spell_decal_218103838 && item.spells.spell_decal_218103838 > 100) {
overlayIconId = (item.spells.spell_decal_218103838 + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
// Icon underlay
if (item.spells.spell_decal_218103848 && item.spells.spell_decal_218103848 > 100) {
underlayIconId = (item.spells.spell_decal_218103848 + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
} else if (item.item_data) {
// Legacy format - parse item_data
try {
const itemData = typeof item.item_data === 'string' ? JSON.parse(item.item_data) : item.item_data;
if (itemData.IntValues) {
// Icon overlay (ID 218103849) - only use valid icon IDs
if (itemData.IntValues['218103849'] && itemData.IntValues['218103849'] > 100) {
overlayIconId = (itemData.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
// Icon underlay (ID 218103850) - only use valid icon IDs
if (itemData.IntValues['218103850'] && itemData.IntValues['218103850'] > 100) {
underlayIconId = (itemData.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
}
} catch (e) {
console.warn('Failed to parse item data for', item.name);
}
}
// Create underlay (bottom layer)
if (underlayIconId) {
const underlayImg = document.createElement('img');
underlayImg.className = 'icon-underlay';
underlayImg.src = `/icons/${underlayIconId}.png`;
underlayImg.alt = 'underlay';
underlayImg.onerror = function() { this.style.display = 'none'; };
iconContainer.appendChild(underlayImg);
}
// Create base icon (middle layer)
const baseImg = document.createElement('img');
baseImg.className = 'icon-base';
baseImg.src = `/icons/${baseIconId}.png`;
baseImg.alt = item.name || 'Unknown Item';
baseImg.onerror = function() {
// Final fallback
this.src = '/icons/06000133.png';
};
iconContainer.appendChild(baseImg);
// Create overlay (top layer)
if (overlayIconId) {
const overlayImg = document.createElement('img');
overlayImg.className = 'icon-overlay';
overlayImg.src = `/icons/${overlayIconId}.png`;
overlayImg.alt = 'overlay';
overlayImg.onerror = function() { this.style.display = 'none'; };
iconContainer.appendChild(overlayImg);
}
// Create tooltip data
slot.dataset.name = item.name || 'Unknown Item';
slot.dataset.value = item.value || 0;
slot.dataset.burden = item.burden || 0;
// Store enhanced data for tooltips
// All data now comes from inventory service (no more local fallback)
if (item.max_damage !== undefined || item.object_class_name !== undefined || item.spells !== undefined) {
// Inventory service provides clean, structured data with translations
// Only include properties that actually exist on the item
const enhancedData = {};
// Check all possible enhanced properties from inventory service
const possibleProps = [
'max_damage', 'armor_level', 'damage_bonus', 'attack_bonus',
'wield_level', 'skill_level', 'lore_requirement', 'equip_skill', 'equip_skill_name',
'material', 'material_name', 'material_id', 'imbue', 'item_set', 'tinks',
'workmanship', 'workmanship_text', 'damage_rating', 'crit_rating',
'heal_boost_rating', 'has_id_data', 'object_class_name', 'spells',
'enhanced_properties', 'damage_range', 'damage_type', 'min_damage',
'speed_text', 'speed_value', 'mana_display', 'spellcraft', 'current_mana', 'max_mana',
'melee_defense_bonus', 'magic_defense_bonus', 'missile_defense_bonus',
'elemental_damage_vs_monsters', 'mana_conversion_bonus', 'icon_overlay_id', 'icon_underlay_id'
];
// Only add properties that exist and have meaningful values
possibleProps.forEach(prop => {
if (item.hasOwnProperty(prop) && item[prop] !== undefined && item[prop] !== null) {
enhancedData[prop] = item[prop];
}
});
slot.dataset.enhancedData = JSON.stringify(enhancedData);
} else {
// No enhanced data available
slot.dataset.enhancedData = JSON.stringify({});
}
// Add tooltip on hover
slot.addEventListener('mouseenter', e => showInventoryTooltip(e, slot));
slot.addEventListener('mousemove', e => showInventoryTooltip(e, slot));
slot.addEventListener('mouseleave', hideInventoryTooltip);
slot.appendChild(iconContainer);
grid.appendChild(slot);
grid.appendChild(createInventorySlot(item));
});
invContent.appendChild(grid);
@ -1075,6 +1125,450 @@ function showInventoryWindow(name) {
debugLog('Inventory window created for:', name);
}
// === TreeStats Property ID Mappings ===
const TS_AUGMENTATIONS = {
218: "Reinforcement of the Lugians", 219: "Bleeargh's Fortitude", 220: "Oswald's Enhancement",
221: "Siraluun's Blessing", 222: "Enduring Calm", 223: "Steadfast Will",
224: "Ciandra's Essence", 225: "Yoshi's Essence", 226: "Jibril's Essence",
227: "Celdiseth's Essence", 228: "Koga's Essence", 229: "Shadow of the Seventh Mule",
230: "Might of the Seventh Mule", 231: "Clutch of the Miser", 232: "Enduring Enchantment",
233: "Critical Protection", 234: "Quick Learner", 235: "Ciandra's Fortune",
236: "Charmed Smith", 237: "Innate Renewal", 238: "Archmage's Endurance",
239: "Enhancement of the Blade Turner", 240: "Enhancement of the Arrow Turner",
241: "Enhancement of the Mace Turner", 242: "Caustic Enhancement", 243: "Fierce Impaler",
244: "Iron Skin of the Invincible", 245: "Eye of the Remorseless", 246: "Hand of the Remorseless",
294: "Master of the Steel Circle", 295: "Master of the Focused Eye",
296: "Master of the Five Fold Path", 297: "Frenzy of the Slayer",
298: "Iron Skin of the Invincible", 299: "Jack of All Trades",
300: "Infused Void Magic", 301: "Infused War Magic",
302: "Infused Life Magic", 309: "Infused Item Magic",
310: "Infused Creature Magic", 326: "Clutch of the Miser",
328: "Enduring Enchantment"
};
const TS_AURAS = {
333: "Valor / Destruction", 334: "Protection", 335: "Glory / Retribution",
336: "Temperance / Hardening", 338: "Aetheric Vision", 339: "Mana Flow",
340: "Mana Infusion", 342: "Purity", 343: "Craftsman", 344: "Specialization",
365: "World"
};
const TS_RATINGS = {
370: "Damage", 371: "Damage Resistance", 372: "Critical", 373: "Critical Resistance",
374: "Critical Damage", 375: "Critical Damage Resistance", 376: "Healing Boost",
379: "Vitality"
};
const TS_SOCIETY = { 287: "Celestial Hand", 288: "Eldrytch Web", 289: "Radiant Blood" };
const TS_MASTERIES = { 354: "Melee", 355: "Ranged", 362: "Summoning" };
const TS_MASTERY_NAMES = { 1: "Unarmed", 2: "Swords", 3: "Axes", 4: "Maces", 5: "Spears", 6: "Daggers", 7: "Staves", 8: "Bows", 9: "Crossbows", 10: "Thrown", 11: "Two-Handed", 12: "Void", 13: "War", 14: "Life" };
const TS_GENERAL = { 181: "Chess Rank", 192: "Fishing Skill", 199: "Total Augmentations", 322: "Aetheria Slots", 390: "Enlightenment" };
function _tsSocietyRank(v) {
if (v >= 1001) return "Master";
if (v >= 301) return "Lord";
if (v >= 151) return "Knight";
if (v >= 31) return "Adept";
return "Initiate";
}
function _tsSetupTabs(container) {
const tabs = container.querySelectorAll('.ts-tab');
const boxes = container.querySelectorAll('.ts-box');
tabs.forEach((tab, i) => {
tab.addEventListener('click', () => {
tabs.forEach(t => { t.classList.remove('active'); t.classList.add('inactive'); });
boxes.forEach(b => { b.classList.remove('active'); b.classList.add('inactive'); });
tab.classList.remove('inactive'); tab.classList.add('active');
if (boxes[i]) { boxes[i].classList.remove('inactive'); boxes[i].classList.add('active'); }
});
});
}
function showCharacterWindow(name) {
debugLog('showCharacterWindow called for:', name);
const windowId = `characterWindow-${name}`;
const { win, content, isNew } = createWindow(
windowId, `Character: ${name}`, 'character-window'
);
if (!isNew) {
debugLog('Existing character window found, showing it');
return;
}
win.dataset.character = name;
characterWindows[name] = win;
const esc = CSS.escape(name);
content.innerHTML = `
<div class="ts-character-header" id="charHeader-${esc}">
<h1>${name} <span class="ts-level"></span></h1>
<div class="ts-subtitle">Awaiting character data...</div>
</div>
<div class="ts-xplum" id="charXpLum-${esc}">
<div class="ts-left">Total XP: \u2014</div>
<div class="ts-right">Unassigned XP: \u2014</div>
<div class="ts-left">Luminance: \u2014</div>
<div class="ts-right">Deaths: \u2014</div>
</div>
<div class="ts-tabrow">
<div class="ts-tabcontainer" id="charTabLeft-${esc}">
<div class="ts-tabbar">
<div class="ts-tab active">Attributes</div>
<div class="ts-tab inactive">Skills</div>
<div class="ts-tab inactive">Titles</div>
</div>
<div class="ts-box active" id="charAttribs-${esc}">
<div class="ts-vitals" id="charVitals-${esc}">
<div class="ts-vital">
<span class="ts-vital-label">Health</span>
<div class="ts-vital-bar ts-health-bar"><div class="ts-vital-fill"></div></div>
<span class="ts-vital-text">\u2014 / \u2014</span>
</div>
<div class="ts-vital">
<span class="ts-vital-label">Stamina</span>
<div class="ts-vital-bar ts-stamina-bar"><div class="ts-vital-fill"></div></div>
<span class="ts-vital-text">\u2014 / \u2014</span>
</div>
<div class="ts-vital">
<span class="ts-vital-label">Mana</span>
<div class="ts-vital-bar ts-mana-bar"><div class="ts-vital-fill"></div></div>
<span class="ts-vital-text">\u2014 / \u2014</span>
</div>
</div>
<table class="ts-char" id="charAttribTable-${esc}">
<tr class="ts-colnames"><td>Attribute</td><td>Creation</td><td>Base</td></tr>
<tr><td class="ts-headerleft">Strength</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
<tr><td class="ts-headerleft">Endurance</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
<tr><td class="ts-headerleft">Coordination</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
<tr><td class="ts-headerleft">Quickness</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
<tr><td class="ts-headerleft">Focus</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
<tr><td class="ts-headerleft">Self</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
</table>
<table class="ts-char" id="charVitalsTable-${esc}">
<tr class="ts-colnames"><td>Vital</td><td>Base</td></tr>
<tr><td class="ts-headerleft">Health</td><td class="ts-headerright">\u2014</td></tr>
<tr><td class="ts-headerleft">Stamina</td><td class="ts-headerright">\u2014</td></tr>
<tr><td class="ts-headerleft">Mana</td><td class="ts-headerright">\u2014</td></tr>
</table>
<table class="ts-char" id="charCredits-${esc}">
<tr><td class="ts-headerleft">Skill Credits</td><td class="ts-headerright">\u2014</td></tr>
</table>
</div>
<div class="ts-box inactive" id="charSkills-${esc}">
<div class="ts-placeholder">Awaiting data...</div>
</div>
<div class="ts-box inactive" id="charTitles-${esc}">
<div class="ts-placeholder">Awaiting data...</div>
</div>
</div>
<div class="ts-tabcontainer" id="charTabRight-${esc}">
<div class="ts-tabbar">
<div class="ts-tab active">Augmentations</div>
<div class="ts-tab inactive">Ratings</div>
<div class="ts-tab inactive">Other</div>
</div>
<div class="ts-box active" id="charAugs-${esc}">
<div class="ts-placeholder">Awaiting data...</div>
</div>
<div class="ts-box inactive" id="charRatings-${esc}">
<div class="ts-placeholder">Awaiting data...</div>
</div>
<div class="ts-box inactive" id="charOther-${esc}">
<div class="ts-placeholder">Awaiting data...</div>
</div>
</div>
</div>
<div class="ts-allegiance-section" id="charAllegiance-${esc}">
<div class="ts-section-title">Allegiance</div>
<div class="ts-placeholder">Awaiting data...</div>
</div>
`;
// Wire up tab switching
const leftTabs = document.getElementById(`charTabLeft-${esc}`);
const rightTabs = document.getElementById(`charTabRight-${esc}`);
if (leftTabs) _tsSetupTabs(leftTabs);
if (rightTabs) _tsSetupTabs(rightTabs);
// Fetch existing data from API
fetch(`${API_BASE}/character-stats/${encodeURIComponent(name)}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data && !data.error) {
characterStats[name] = data;
updateCharacterWindow(name, data);
}
})
.catch(err => handleError('Character stats', err));
// If we already have vitals from the live stream, apply them
if (characterVitals[name]) {
updateCharacterVitals(name, characterVitals[name]);
}
}
function updateCharacterWindow(name, data) {
const esc = CSS.escape(name);
const fmt = n => n != null ? n.toLocaleString() : '\u2014';
// -- Header --
const header = document.getElementById(`charHeader-${esc}`);
if (header) {
const level = data.level || '?';
const race = data.race || '';
const gender = data.gender || '';
const parts = [gender, race].filter(Boolean).join(' ');
header.querySelector('.ts-subtitle').textContent = parts || 'Awaiting data...';
const levelSpan = header.querySelector('.ts-level');
if (levelSpan) levelSpan.textContent = level;
}
// -- XP / Luminance row --
const xplum = document.getElementById(`charXpLum-${esc}`);
if (xplum) {
const divs = xplum.querySelectorAll('div');
if (divs[0]) divs[0].textContent = `Total XP: ${fmt(data.total_xp)}`;
if (divs[1]) divs[1].textContent = `Unassigned XP: ${fmt(data.unassigned_xp)}`;
if (divs[2]) {
const lum = data.luminance_earned != null && data.luminance_total != null
? `${fmt(data.luminance_earned)} / ${fmt(data.luminance_total)}`
: '\u2014';
divs[2].textContent = `Luminance: ${lum}`;
}
if (divs[3]) divs[3].textContent = `Deaths: ${fmt(data.deaths)}`;
}
// -- Attributes table --
const attribTable = document.getElementById(`charAttribTable-${esc}`);
if (attribTable && data.attributes) {
const order = ['strength', 'endurance', 'coordination', 'quickness', 'focus', 'self'];
const rows = attribTable.querySelectorAll('tr:not(.ts-colnames)');
order.forEach((attr, i) => {
if (rows[i] && data.attributes[attr]) {
const cells = rows[i].querySelectorAll('td');
if (cells[1]) cells[1].textContent = data.attributes[attr].creation ?? '\u2014';
if (cells[2]) cells[2].textContent = data.attributes[attr].base ?? '\u2014';
}
});
}
// -- Vitals table (base values) --
const vitalsTable = document.getElementById(`charVitalsTable-${esc}`);
if (vitalsTable && data.vitals) {
const vOrder = ['health', 'stamina', 'mana'];
const vRows = vitalsTable.querySelectorAll('tr:not(.ts-colnames)');
vOrder.forEach((v, i) => {
if (vRows[i] && data.vitals[v]) {
const cells = vRows[i].querySelectorAll('td');
if (cells[1]) cells[1].textContent = data.vitals[v].base ?? '\u2014';
}
});
}
// -- Skill credits --
const creditsTable = document.getElementById(`charCredits-${esc}`);
if (creditsTable) {
const cell = creditsTable.querySelector('td.ts-headerright');
if (cell) cell.textContent = fmt(data.skill_credits);
}
// -- Skills tab --
const skillsBox = document.getElementById(`charSkills-${esc}`);
if (skillsBox && data.skills) {
const grouped = { Specialized: [], Trained: [] };
for (const [skill, info] of Object.entries(data.skills)) {
const training = info.training || 'Untrained';
if (training === 'Untrained' || training === 'Unusable') continue;
const displayName = skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
if (grouped[training]) grouped[training].push({ name: displayName, base: info.base });
}
for (const g of Object.values(grouped)) g.sort((a, b) => a.name.localeCompare(b.name));
let html = '<table class="ts-char">';
html += '<tr class="ts-colnames"><td>Skill</td><td>Level</td></tr>';
if (grouped.Specialized.length) {
for (const s of grouped.Specialized) {
html += `<tr><td class="ts-specialized">${s.name}</td><td class="ts-specialized" style="text-align:right">${s.base}</td></tr>`;
}
}
if (grouped.Trained.length) {
for (const s of grouped.Trained) {
html += `<tr><td class="ts-trained">${s.name}</td><td class="ts-trained" style="text-align:right">${s.base}</td></tr>`;
}
}
html += '</table>';
skillsBox.innerHTML = html;
}
// -- Titles tab --
const titlesBox = document.getElementById(`charTitles-${esc}`);
if (titlesBox) {
const statsData = data.stats_data || data;
const titles = statsData.titles;
if (titles && titles.length > 0) {
let html = '<div class="ts-titles-list">';
for (const t of titles) html += `<div>${t}</div>`;
html += '</div>';
titlesBox.innerHTML = html;
} else {
titlesBox.innerHTML = '<div class="ts-placeholder">No titles data</div>';
}
}
// -- Properties-based tabs (Augmentations, Ratings, Other) --
const statsData = data.stats_data || data;
const props = statsData.properties || {};
// Augmentations tab
const augsBox = document.getElementById(`charAugs-${esc}`);
if (augsBox) {
let augRows = [], auraRows = [];
for (const [id, val] of Object.entries(props)) {
const nid = parseInt(id);
if (TS_AUGMENTATIONS[nid] && val > 0) augRows.push({ name: TS_AUGMENTATIONS[nid], uses: val });
if (TS_AURAS[nid] && val > 0) auraRows.push({ name: TS_AURAS[nid], uses: val });
}
if (augRows.length || auraRows.length) {
let html = '';
if (augRows.length) {
html += '<div class="ts-section-title">Augmentations</div>';
html += '<table class="ts-props"><tr class="ts-colnames"><td>Name</td><td>Uses</td></tr>';
for (const a of augRows) html += `<tr><td>${a.name}</td><td style="text-align:right">${a.uses}</td></tr>`;
html += '</table>';
}
if (auraRows.length) {
html += '<div class="ts-section-title">Auras</div>';
html += '<table class="ts-props"><tr class="ts-colnames"><td>Name</td><td>Uses</td></tr>';
for (const a of auraRows) html += `<tr><td>${a.name}</td><td style="text-align:right">${a.uses}</td></tr>`;
html += '</table>';
}
augsBox.innerHTML = html;
} else {
augsBox.innerHTML = '<div class="ts-placeholder">No augmentation data</div>';
}
}
// Ratings tab
const ratingsBox = document.getElementById(`charRatings-${esc}`);
if (ratingsBox) {
let rows = [];
for (const [id, val] of Object.entries(props)) {
const nid = parseInt(id);
if (TS_RATINGS[nid] && val > 0) rows.push({ name: TS_RATINGS[nid], value: val });
}
if (rows.length) {
let html = '<table class="ts-props"><tr class="ts-colnames"><td>Rating</td><td>Value</td></tr>';
for (const r of rows) html += `<tr><td>${r.name}</td><td style="text-align:right">${r.value}</td></tr>`;
html += '</table>';
ratingsBox.innerHTML = html;
} else {
ratingsBox.innerHTML = '<div class="ts-placeholder">No rating data</div>';
}
}
// Other tab (General, Masteries, Society)
const otherBox = document.getElementById(`charOther-${esc}`);
if (otherBox) {
let html = '';
// General section
let generalRows = [];
if (data.birth) generalRows.push({ name: 'Birth', value: data.birth });
if (data.deaths != null) generalRows.push({ name: 'Deaths', value: fmt(data.deaths) });
for (const [id, val] of Object.entries(props)) {
const nid = parseInt(id);
if (TS_GENERAL[nid]) generalRows.push({ name: TS_GENERAL[nid], value: val });
}
if (generalRows.length) {
html += '<div class="ts-section-title">General</div>';
html += '<table class="ts-props">';
for (const r of generalRows) html += `<tr><td>${r.name}</td><td style="text-align:right">${r.value}</td></tr>`;
html += '</table>';
}
// Masteries section
let masteryRows = [];
for (const [id, val] of Object.entries(props)) {
const nid = parseInt(id);
if (TS_MASTERIES[nid]) {
const mName = TS_MASTERY_NAMES[val] || `Unknown (${val})`;
masteryRows.push({ name: TS_MASTERIES[nid], value: mName });
}
}
if (masteryRows.length) {
html += '<div class="ts-section-title">Masteries</div>';
html += '<table class="ts-props">';
for (const m of masteryRows) html += `<tr><td>${m.name}</td><td style="text-align:right">${m.value}</td></tr>`;
html += '</table>';
}
// Society section
let societyRows = [];
for (const [id, val] of Object.entries(props)) {
const nid = parseInt(id);
if (TS_SOCIETY[nid] && val > 0) {
societyRows.push({ name: TS_SOCIETY[nid], rank: _tsSocietyRank(val), value: val });
}
}
if (societyRows.length) {
html += '<div class="ts-section-title">Society</div>';
html += '<table class="ts-props">';
for (const s of societyRows) html += `<tr><td>${s.name}</td><td style="text-align:right">${s.rank} (${s.value})</td></tr>`;
html += '</table>';
}
otherBox.innerHTML = html || '<div class="ts-placeholder">No additional data</div>';
}
// -- Allegiance section --
const allegDiv = document.getElementById(`charAllegiance-${esc}`);
if (allegDiv && data.allegiance) {
const a = data.allegiance;
let html = '<div class="ts-section-title">Allegiance</div>';
html += '<table class="ts-allegiance">';
if (a.name) html += `<tr><td>Name</td><td>${a.name}</td></tr>`;
if (a.monarch) html += `<tr><td>Monarch</td><td>${a.monarch.name || '\u2014'}</td></tr>`;
if (a.patron) html += `<tr><td>Patron</td><td>${a.patron.name || '\u2014'}</td></tr>`;
if (a.rank !== undefined) html += `<tr><td>Rank</td><td>${a.rank}</td></tr>`;
if (a.followers !== undefined) html += `<tr><td>Followers</td><td>${a.followers}</td></tr>`;
html += '</table>';
allegDiv.innerHTML = html;
}
}
function updateCharacterVitals(name, vitals) {
const esc = CSS.escape(name);
const vitalsDiv = document.getElementById(`charVitals-${esc}`);
if (!vitalsDiv) return;
const vitalElements = vitalsDiv.querySelectorAll('.ts-vital');
if (vitalElements[0]) {
const fill = vitalElements[0].querySelector('.ts-vital-fill');
const txt = vitalElements[0].querySelector('.ts-vital-text');
if (fill) fill.style.width = `${vitals.health_percentage || 0}%`;
if (txt && vitals.health_current !== undefined) {
txt.textContent = `${vitals.health_current} / ${vitals.health_max}`;
}
}
if (vitalElements[1]) {
const fill = vitalElements[1].querySelector('.ts-vital-fill');
const txt = vitalElements[1].querySelector('.ts-vital-text');
if (fill) fill.style.width = `${vitals.stamina_percentage || 0}%`;
if (txt && vitals.stamina_current !== undefined) {
txt.textContent = `${vitals.stamina_current} / ${vitals.stamina_max}`;
}
}
if (vitalElements[2]) {
const fill = vitalElements[2].querySelector('.ts-vital-fill');
const txt = vitalElements[2].querySelector('.ts-vital-text');
if (fill) fill.style.width = `${vitals.mana_percentage || 0}%`;
if (txt && vitals.mana_current !== undefined) {
txt.textContent = `${vitals.mana_current} / ${vitals.mana_max}`;
}
}
}
// Inventory tooltip functions
let inventoryTooltip = null;
@ -1841,6 +2335,11 @@ function initWebSocket() {
updateVitalsDisplay(msg);
} else if (msg.type === 'rare') {
triggerEpicRareNotification(msg.character_name, msg.name);
} else if (msg.type === 'character_stats') {
characterStats[msg.character_name] = msg;
updateCharacterWindow(msg.character_name, msg);
} else if (msg.type === 'inventory_delta') {
updateInventoryLive(msg);
} else if (msg.type === 'server_status') {
handleServerStatusUpdate(msg);
}
@ -1997,6 +2496,8 @@ wrap.addEventListener('mouseleave', () => {
/* ---------- vitals display functions ----------------------------- */
// Store vitals data per character
const characterVitals = {};
const characterStats = {};
const characterWindows = {};
function updateVitalsDisplay(vitalsMsg) {
// Store the vitals data for this character
@ -2004,11 +2505,20 @@ function updateVitalsDisplay(vitalsMsg) {
health_percentage: vitalsMsg.health_percentage,
stamina_percentage: vitalsMsg.stamina_percentage,
mana_percentage: vitalsMsg.mana_percentage,
health_current: vitalsMsg.health_current,
health_max: vitalsMsg.health_max,
stamina_current: vitalsMsg.stamina_current,
stamina_max: vitalsMsg.stamina_max,
mana_current: vitalsMsg.mana_current,
mana_max: vitalsMsg.mana_max,
vitae: vitalsMsg.vitae
};
// Re-render the player list to update vitals in the UI
renderList();
// Also update character window if open
updateCharacterVitals(vitalsMsg.character_name, characterVitals[vitalsMsg.character_name]);
}
function createVitalsHTML(characterName) {

View file

@ -525,7 +525,7 @@ body {
margin-top: 4px;
}
.chat-window, .stats-window, .inventory-window {
.chat-window, .stats-window, .inventory-window, .character-window {
position: absolute;
top: 10px;
/* position window to start just right of the sidebar */
@ -1590,3 +1590,261 @@ body.noselect, body.noselect * {
to { opacity: 1; transform: translateY(0); }
}
/* ============================================
Character Window - AC Game UI Replica
============================================ */
/* === TreeStats-themed Character Window === */
.character-window {
width: 740px !important;
height: auto !important;
min-height: 300px;
max-height: 90vh;
}
.character-window .window-content {
background-color: #000022;
color: #fff;
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow-y: auto;
padding: 10px 15px 15px;
}
/* -- Character header (name, level, title, server, XP/Lum) -- */
.ts-character-header {
margin-bottom: 10px;
}
.ts-character-header h1 {
margin: 0 0 2px;
font-size: 28px;
color: #fff;
font-weight: bold;
}
.ts-character-header h1 span.ts-level {
font-size: 200%;
color: #fff27f;
float: right;
}
.ts-character-header .ts-subtitle {
font-size: 85%;
color: gold;
}
.ts-xplum {
font-size: 85%;
margin: 6px 0 10px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 20px;
}
.ts-xplum .ts-left { text-align: left; }
.ts-xplum .ts-right { text-align: right; }
/* -- Tab containers (two side-by-side) -- */
.ts-tabrow {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.ts-tabcontainer {
width: 320px;
margin-bottom: 15px;
}
.ts-tabbar {
height: 30px;
display: flex;
}
.ts-tab {
float: left;
display: block;
padding: 5px 5px;
height: 18px;
font-size: 12px;
font-weight: bold;
color: #fff;
text-align: center;
cursor: pointer;
user-select: none;
}
.ts-tab.active {
border-top: 2px solid #af7a30;
border-right: 2px solid #af7a30;
border-left: 2px solid #af7a30;
border-bottom: none;
background-color: rgba(0, 100, 0, 0.4);
}
.ts-tab.inactive {
border-top: 2px solid #000022;
border-right: 2px solid #000022;
border-left: 2px solid #000022;
border-bottom: none;
}
.ts-box {
background-color: black;
color: #fff;
border: 2px solid #af7a30;
max-height: 400px;
overflow-x: hidden;
overflow-y: auto;
}
.ts-box.active { display: block; }
.ts-box.inactive { display: none; }
/* -- Tables inside boxes -- */
table.ts-char {
width: 100%;
font-size: 13px;
border-collapse: collapse;
border-spacing: 0;
}
table.ts-char td {
padding: 2px 6px;
white-space: nowrap;
}
table.ts-char tr.ts-colnames td {
background-color: #222;
font-weight: bold;
font-size: 12px;
}
/* Attribute cells */
table.ts-char td.ts-headerleft {
background-color: rgba(0, 100, 0, 0.4);
}
table.ts-char td.ts-headerright {
background-color: rgba(0, 0, 100, 0.4);
}
table.ts-char td.ts-creation {
color: #ccc;
}
/* Skill rows */
table.ts-char td.ts-specialized {
background: linear-gradient(to right, #392067, #392067, black);
}
table.ts-char td.ts-trained {
background: linear-gradient(to right, #0f3c3e, #0f3c3e, black);
}
/* Section headers inside boxes */
.ts-box .ts-section-title {
background-color: #222;
padding: 4px 8px;
font-weight: bold;
font-size: 13px;
border-bottom: 1px solid #af7a30;
}
/* Titles list */
.ts-titles-list {
padding: 6px 10px;
font-size: 13px;
}
.ts-titles-list div {
padding: 1px 0;
}
/* Properties (augmentations, ratings, other) */
table.ts-props {
width: 100%;
font-size: 13px;
border-collapse: collapse;
}
table.ts-props td {
padding: 2px 6px;
}
table.ts-props tr.ts-colnames td {
background-color: #222;
font-weight: bold;
}
/* -- Live vitals bars (inside Attributes tab) -- */
.ts-vitals {
padding: 6px 8px;
display: flex;
flex-direction: column;
gap: 4px;
border-bottom: 2px solid #af7a30;
}
.ts-vital {
display: flex;
align-items: center;
gap: 6px;
}
.ts-vital-label {
width: 55px;
font-size: 12px;
color: #ccc;
}
.ts-vital-bar {
flex: 1;
height: 14px;
overflow: hidden;
position: relative;
border: 1px solid #af7a30;
}
.ts-vital-fill {
height: 100%;
transition: width 0.5s ease;
}
.ts-health-bar .ts-vital-fill { background: #cc3333; width: 0%; }
.ts-stamina-bar .ts-vital-fill { background: #ccaa33; width: 0%; }
.ts-mana-bar .ts-vital-fill { background: #3366cc; width: 0%; }
.ts-vital-text {
width: 80px;
text-align: right;
font-size: 12px;
color: #ccc;
}
/* -- Allegiance section (below tabs) -- */
.ts-allegiance-section {
margin-top: 5px;
border: 2px solid #af7a30;
background-color: black;
padding: 0;
}
.ts-allegiance-section .ts-section-title {
background-color: #222;
padding: 4px 8px;
font-weight: bold;
font-size: 13px;
border-bottom: 1px solid #af7a30;
}
table.ts-allegiance {
width: 100%;
font-size: 13px;
border-collapse: collapse;
}
table.ts-allegiance td {
padding: 2px 6px;
}
table.ts-allegiance td:first-child {
color: #ccc;
width: 100px;
}
/* Awaiting data placeholder */
.ts-placeholder {
color: #666;
font-style: italic;
padding: 10px;
text-align: center;
}
/* Scrollbar styling for ts-box */
.ts-box::-webkit-scrollbar { width: 8px; }
.ts-box::-webkit-scrollbar-track { background: #000; }
.ts-box::-webkit-scrollbar-thumb { background: #af7a30; }
.char-btn {
background: #000022;
color: #af7a30;
border: 1px solid #af7a30;
padding: 2px 6px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
.char-btn:hover {
background: rgba(0, 100, 0, 0.4);
border-color: #af7a30;
}

View file

@ -181,6 +181,10 @@
<input type="checkbox" id="cantrip_legendary_twohanded" value="Legendary Two Handed Combat Aptitude">
<label for="cantrip_legendary_twohanded">Two-handed</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_dualwield" value="Legendary Dual Wield Aptitude">
<label for="cantrip_legendary_dualwield">Dual Wield</label>
</div>
<!-- Legendary Magic Skills -->
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_war" value="Legendary War Magic Aptitude">
@ -211,6 +215,23 @@
<input type="checkbox" id="cantrip_legendary_melee_defense" value="Legendary Invulnerability">
<label for="cantrip_legendary_melee_defense">Melee Def</label>
</div>
<!-- Legendary Combat Skills -->
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_recklessness" value="Legendary Recklessness Prowess">
<label for="cantrip_legendary_recklessness">Recklessness</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_deception" value="Legendary Deception Prowess">
<label for="cantrip_legendary_deception">Deception</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_sneak_attack" value="Legendary Sneak Attack Prowess">
<label for="cantrip_legendary_sneak_attack">Sneak Attack</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_dirty_fighting" value="Legendary Dirty Fighting Prowess">
<label for="cantrip_legendary_dirty_fighting">Dirty Fighting</label>
</div>
</div>
</div>
@ -475,4 +496,4 @@
<script src="suitbuilder.js"></script>
</body>
</html>
</html>

View file

@ -31,7 +31,13 @@ const COMMON_CANTRIPS = [
'Legendary Life Magic Aptitude',
// Defense
'Legendary Magic Resistance',
'Legendary Invulnerability'
'Legendary Invulnerability',
// Combat Skills
'Legendary Recklessness Prowess',
'Legendary Dual Wield Aptitude',
'Legendary Deception Prowess',
'Legendary Sneak Attack Prowess',
'Legendary Dirty Fighting Prowess'
];
// Common legendary wards for lock form