Compare commits
14 commits
40198fa0cf
...
7050cfb8b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7050cfb8b7 | ||
|
|
973c3722bc | ||
|
|
f145e6e131 | ||
|
|
749652d534 | ||
|
|
664bd50388 | ||
|
|
176fb020ec | ||
|
|
45cedd0ec9 | ||
|
|
9c91ed0afb | ||
|
|
10bb6c30dd | ||
|
|
a545a8b920 | ||
|
|
e71dfb4ec3 | ||
|
|
ab9f86d7a6 | ||
|
|
a824451365 | ||
|
|
7d52ac2fe4 |
12 changed files with 3708 additions and 154 deletions
35
db_async.py
35
db_async.py
|
|
@ -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:
|
||||
|
|
|
|||
308
docs/plans/2026-02-26-character-stats-design.md
Normal file
308
docs/plans/2026-02-26-character-stats-design.md
Normal 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)
|
||||
1119
docs/plans/2026-02-26-character-stats-plan.md
Normal file
1119
docs/plans/2026-02-26-character-stats-plan.md
Normal file
File diff suppressed because it is too large
Load diff
201
docs/plans/2026-02-26-plugin-character-stats-design.md
Normal file
201
docs/plans/2026-02-26-plugin-character-stats-design.md
Normal 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)
|
||||
576
docs/plans/2026-02-26-plugin-character-stats-plan.md
Normal file
576
docs/plans/2026-02-26-plugin-character-stats-plan.md
Normal 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 |
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
281
main.py
|
|
@ -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
|
||||
|
|
|
|||
778
static/script.js
778
static/script.js
|
|
@ -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) {
|
||||
|
|
|
|||
260
static/style.css
260
static/style.css
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue