diff --git a/main.py b/main.py
index 3ddab2cc..5799906b 100644
--- a/main.py
+++ b/main.py
@@ -779,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"
@@ -875,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.
@@ -1963,6 +1991,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")
@@ -2246,6 +2330,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
diff --git a/static/script.js b/static/script.js
index ef44795d..54090c70 100644
--- a/static/script.js
+++ b/static/script.js
@@ -1088,6 +1088,63 @@ 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}`;
@@ -1106,71 +1163,98 @@ function showCharacterWindow(name) {
const esc = CSS.escape(name);
content.innerHTML = `
-
-
-
-
Attributes
-
-
-
Strength\u2014
-
Quickness\u2014
-
-
-
Endurance\u2014
-
Focus\u2014
-
-
-
Coordination\u2014
-
Self\u2014
+
+
+
Total XP: \u2014
+
Unassigned XP: \u2014
+
Luminance: \u2014
+
Deaths: \u2014
+
+
+
+
+
Attributes
+
Skills
+
Titles
+
+
+
+
+
Health
+
+
\u2014 / \u2014
+
+
+
Stamina
+
+
\u2014 / \u2014
+
+
+
Mana
+
+
\u2014 / \u2014
+
+
+ | Attribute | Creation | Base |
+ | \u2014 |
+ | \u2014 |
+ | \u2014 |
+ | \u2014 |
+ | \u2014 |
+ | \u2014 |
+
+
+
+
+
+
-
-
Vitals
-
-
-
Health
-
-
\u2014 / \u2014
-
-
-
Stamina
-
-
\u2014 / \u2014
-
-
-
Mana
-
-
\u2014 / \u2014
-
+
+
+
Augmentations
+
Ratings
+
Other
+
+
+
+
-
-
-
+
+
+
Allegiance
+
Awaiting data...
`;
+ // 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}/api/character-stats/${encodeURIComponent(name)}`)
+ fetch(`${API_BASE}/character-stats/${encodeURIComponent(name)}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data && !data.error) {
@@ -1187,130 +1271,260 @@ function showCharacterWindow(name) {
}
function updateCharacterWindow(name, data) {
- const escapedName = CSS.escape(name);
+ const esc = CSS.escape(name);
+ const fmt = n => n != null ? n.toLocaleString() : '\u2014';
- // Header
- const header = document.getElementById(`charHeader-${escapedName}`);
+ // -- Header --
+ const header = document.getElementById(`charHeader-${esc}`);
if (header) {
const level = data.level || '?';
const race = data.race || '';
const gender = data.gender || '';
- const subtitle = [`Level ${level}`, race, gender].filter(Boolean).join(' \u00b7 ');
- header.querySelector('.ac-subtitle').textContent = subtitle;
+ 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;
}
- // Attributes
- const attribs = document.getElementById(`charAttribs-${escapedName}`);
- if (attribs && data.attributes) {
- const order = [
- ['strength', 'quickness'],
- ['endurance', 'focus'],
- ['coordination', 'self']
- ];
- const rows = attribs.querySelectorAll('.ac-attr-row');
- order.forEach((pair, i) => {
- if (rows[i]) {
- const cells = rows[i].querySelectorAll('.ac-attr-value');
- pair.forEach((attr, j) => {
- if (cells[j] && data.attributes[attr]) {
- const val = data.attributes[attr].base || '\u2014';
- const creation = data.attributes[attr].creation;
- cells[j].textContent = val;
- if (creation !== undefined) {
- cells[j].title = `Creation: ${creation}`;
- }
- }
- });
+ // -- 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';
}
});
}
- // Skills
- const skillsDiv = document.getElementById(`charSkills-${escapedName}`);
- if (skillsDiv && data.skills) {
- const grouped = { Specialized: [], Trained: [], Untrained: [] };
+ // -- 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 });
+ 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 = '
';
+ html += '| Skill | Level |
';
+ if (grouped.Specialized.length) {
+ for (const s of grouped.Specialized) {
+ html += `| ${s.name} | ${s.base} |
`;
}
}
- for (const group of Object.values(grouped)) {
- group.sort((a, b) => b.base - a.base);
- }
- let html = '';
- for (const [training, skills] of Object.entries(grouped)) {
- if (skills.length === 0) continue;
- html += ``;
- html += `
${training}
`;
- for (const s of skills) {
- html += `
`;
- html += `${s.name}`;
- html += `${s.base}`;
- html += `
`;
+ if (grouped.Trained.length) {
+ for (const s of grouped.Trained) {
+ html += `
| ${s.name} | ${s.base} |
`;
}
- html += `
`;
}
- skillsDiv.innerHTML = html;
+ html += '
';
+ skillsBox.innerHTML = html;
}
- // Allegiance
- const allegianceDiv = document.getElementById(`charAllegiance-${escapedName}`);
- if (allegianceDiv && data.allegiance) {
+ // -- 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 = '
';
+ for (const t of titles) html += `
${t}
`;
+ html += '
';
+ titlesBox.innerHTML = html;
+ } else {
+ titlesBox.innerHTML = '
No titles data
';
+ }
+ }
+
+ // -- 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 += '
Augmentations
';
+ html += '
| Name | Uses |
';
+ for (const a of augRows) html += `| ${a.name} | ${a.uses} |
`;
+ html += '
';
+ }
+ if (auraRows.length) {
+ html += '
Auras
';
+ html += '
| Name | Uses |
';
+ for (const a of auraRows) html += `| ${a.name} | ${a.uses} |
`;
+ html += '
';
+ }
+ augsBox.innerHTML = html;
+ } else {
+ augsBox.innerHTML = '
No augmentation data
';
+ }
+ }
+
+ // 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 = '
| Rating | Value |
';
+ for (const r of rows) html += `| ${r.name} | ${r.value} |
`;
+ html += '
';
+ ratingsBox.innerHTML = html;
+ } else {
+ ratingsBox.innerHTML = '
No rating data
';
+ }
+ }
+
+ // 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 += '
General
';
+ html += '
';
+ for (const r of generalRows) html += `| ${r.name} | ${r.value} |
`;
+ html += '
';
+ }
+
+ // 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 += '
Masteries
';
+ html += '
';
+ for (const m of masteryRows) html += `| ${m.name} | ${m.value} |
`;
+ html += '
';
+ }
+
+ // 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 += '
Society
';
+ html += '
';
+ for (const s of societyRows) html += `| ${s.name} | ${s.rank} (${s.value}) |
`;
+ html += '
';
+ }
+
+ otherBox.innerHTML = html || '
No additional data
';
+ }
+
+ // -- Allegiance section --
+ const allegDiv = document.getElementById(`charAllegiance-${esc}`);
+ if (allegDiv && data.allegiance) {
const a = data.allegiance;
- let html = '';
- if (a.name) html += `
Allegiance:${a.name}
`;
- if (a.monarch) html += `
Monarch:${a.monarch.name || '\u2014'}
`;
- if (a.patron) html += `
Patron:${a.patron.name || '\u2014'}
`;
- if (a.rank !== undefined) html += `
Rank:${a.rank}
`;
- if (a.followers !== undefined) html += `
Followers:${a.followers}
`;
- allegianceDiv.innerHTML = html || '
No allegiance
';
- }
-
- // Footer
- const footer = document.getElementById(`charFooter-${escapedName}`);
- if (footer) {
- const rows = footer.querySelectorAll('.ac-footer-row');
- const formatNum = n => n != null ? n.toLocaleString() : '\u2014';
- if (rows[0]) rows[0].querySelector('span:last-child').textContent = formatNum(data.total_xp);
- if (rows[1]) rows[1].querySelector('span:last-child').textContent = formatNum(data.unassigned_xp);
- if (rows[2]) {
- const lum = data.luminance_earned != null && data.luminance_total != null
- ? `${formatNum(data.luminance_earned)} / ${formatNum(data.luminance_total)}`
- : '\u2014';
- rows[2].querySelector('span:last-child').textContent = lum;
- }
- if (rows[3]) rows[3].querySelector('span:last-child').textContent = formatNum(data.deaths);
+ let html = '
Allegiance
';
+ html += '
';
+ if (a.name) html += `| Name | ${a.name} |
`;
+ if (a.monarch) html += `| Monarch | ${a.monarch.name || '\u2014'} |
`;
+ if (a.patron) html += `| Patron | ${a.patron.name || '\u2014'} |
`;
+ if (a.rank !== undefined) html += `| Rank | ${a.rank} |
`;
+ if (a.followers !== undefined) html += `| Followers | ${a.followers} |
`;
+ html += '
';
+ allegDiv.innerHTML = html;
}
}
function updateCharacterVitals(name, vitals) {
- const escapedName = CSS.escape(name);
- const vitalsDiv = document.getElementById(`charVitals-${escapedName}`);
+ const esc = CSS.escape(name);
+ const vitalsDiv = document.getElementById(`charVitals-${esc}`);
if (!vitalsDiv) return;
- const vitalElements = vitalsDiv.querySelectorAll('.ac-vital');
+ const vitalElements = vitalsDiv.querySelectorAll('.ts-vital');
if (vitalElements[0]) {
- const fill = vitalElements[0].querySelector('.ac-vital-fill');
- const txt = vitalElements[0].querySelector('.ac-vital-text');
+ 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('.ac-vital-fill');
- const txt = vitalElements[1].querySelector('.ac-vital-text');
+ 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('.ac-vital-fill');
- const txt = vitalElements[2].querySelector('.ac-vital-text');
+ 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}`;
diff --git a/static/style.css b/static/style.css
index b9213485..d958bc8b 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1593,202 +1593,258 @@ body.noselect, body.noselect * {
/* ============================================
Character Window - AC Game UI Replica
============================================ */
+/* === TreeStats-themed Character Window === */
.character-window {
- width: 450px !important;
- height: 650px !important;
+ 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;
}
-.ac-panel {
+/* -- 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;
- flex-direction: column;
- height: 100%;
- background: linear-gradient(135deg, #1a1410 0%, #2a2218 50%, #1a1410 100%);
- color: #c8b89a;
- font-size: 13px;
+ 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; }
-.ac-header {
- padding: 12px 15px;
- border-bottom: 1px solid #8b7355;
- text-align: center;
+/* -- Tables inside boxes -- */
+table.ts-char {
+ width: 100%;
+ font-size: 13px;
+ border-collapse: collapse;
+ border-spacing: 0;
}
-.ac-name {
- font-size: 18px;
+table.ts-char td {
+ padding: 2px 6px;
+ white-space: nowrap;
+}
+table.ts-char tr.ts-colnames td {
+ background-color: #222;
font-weight: bold;
- color: #d4a843;
- letter-spacing: 1px;
-}
-.ac-subtitle {
font-size: 12px;
- color: #7a6e5e;
- margin-top: 4px;
}
-.ac-section {
- padding: 8px 15px;
- border-bottom: 1px solid #3a3228;
+/* Attribute cells */
+table.ts-char td.ts-headerleft {
+ background-color: rgba(0, 100, 0, 0.4);
}
-.ac-section-title {
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 2px;
- color: #8b7355;
- margin-bottom: 6px;
- padding-bottom: 3px;
- border-bottom: 1px solid #3a3228;
+table.ts-char td.ts-headerright {
+ background-color: rgba(0, 0, 100, 0.4);
+}
+table.ts-char td.ts-creation {
+ color: #ccc;
}
-.ac-attributes {
- display: flex;
- flex-direction: column;
- gap: 2px;
+/* Skill rows */
+table.ts-char td.ts-specialized {
+ background: linear-gradient(to right, #392067, #392067, black);
}
-.ac-attr-row {
- display: flex;
- gap: 10px;
+table.ts-char td.ts-trained {
+ background: linear-gradient(to right, #0f3c3e, #0f3c3e, black);
}
-.ac-attr {
- flex: 1;
- display: flex;
- justify-content: space-between;
- padding: 3px 8px;
- background: rgba(42, 34, 24, 0.6);
- border: 1px solid #3a3228;
- border-radius: 2px;
+
+/* 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;
}
-.ac-attr-label {
- color: #7a6e5e;
+
+/* Titles list */
+.ts-titles-list {
+ padding: 6px 10px;
+ font-size: 13px;
}
-.ac-attr-value {
- color: #d4a843;
+.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;
}
-.ac-vitals {
+/* -- Live vitals bars (inside Attributes tab) -- */
+.ts-vitals {
+ padding: 6px 8px;
display: flex;
flex-direction: column;
- gap: 6px;
+ gap: 4px;
+ border-bottom: 2px solid #af7a30;
}
-.ac-vital {
+.ts-vital {
display: flex;
align-items: center;
- gap: 8px;
+ gap: 6px;
}
-.ac-vital-label {
+.ts-vital-label {
width: 55px;
font-size: 12px;
- color: #7a6e5e;
+ color: #ccc;
}
-.ac-vital-bar {
+.ts-vital-bar {
flex: 1;
- height: 16px;
- border-radius: 2px;
+ height: 14px;
overflow: hidden;
position: relative;
+ border: 1px solid #af7a30;
}
-.ac-vital-fill {
+.ts-vital-fill {
height: 100%;
transition: width 0.5s ease;
- border-radius: 2px;
}
-.ac-health-bar { background: #4a1a1a; }
-.ac-health-bar .ac-vital-fill { background: #cc3333; width: 0%; }
-.ac-stamina-bar { background: #4a3a1a; }
-.ac-stamina-bar .ac-vital-fill { background: #ccaa33; width: 0%; }
-.ac-mana-bar { background: #1a2a4a; }
-.ac-mana-bar .ac-vital-fill { background: #3366cc; width: 0%; }
-.ac-vital-text {
+.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: #c8b89a;
+ color: #ccc;
}
-.ac-skills-section {
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
+/* -- Allegiance section (below tabs) -- */
+.ts-allegiance-section {
+ margin-top: 5px;
+ border: 2px solid #af7a30;
+ background-color: black;
+ padding: 0;
}
-.ac-skills {
- overflow-y: auto;
- max-height: 200px;
- flex: 1;
-}
-.ac-skill-group {
- margin-bottom: 4px;
-}
-.ac-skill-group-title {
- font-size: 11px;
+.ts-allegiance-section .ts-section-title {
+ background-color: #222;
+ padding: 4px 8px;
font-weight: bold;
- text-transform: uppercase;
- letter-spacing: 1px;
- padding: 3px 8px;
- margin-bottom: 1px;
+ 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;
}
-.ac-skill-group-title.ac-specialized { color: #d4a843; }
-.ac-skill-group-title.ac-trained { color: #c8b89a; }
-.ac-skill-group-title.ac-untrained { color: #5a5248; }
-.ac-skill-row {
- display: flex;
- justify-content: space-between;
- padding: 2px 12px;
- border-bottom: 1px solid rgba(58, 50, 40, 0.4);
+/* Awaiting data placeholder */
+.ts-placeholder {
+ color: #666;
+ font-style: italic;
+ padding: 10px;
+ text-align: center;
}
-.ac-skill-row:hover {
- background: rgba(139, 115, 85, 0.1);
-}
-.ac-skill-row.ac-specialized .ac-skill-name { color: #d4a843; }
-.ac-skill-row.ac-specialized .ac-skill-value { color: #d4a843; font-weight: bold; }
-.ac-skill-row.ac-trained .ac-skill-name { color: #c8b89a; }
-.ac-skill-row.ac-trained .ac-skill-value { color: #c8b89a; }
-.ac-skill-row.ac-untrained .ac-skill-name { color: #5a5248; }
-.ac-skill-row.ac-untrained .ac-skill-value { color: #5a5248; }
-.ac-skill-name { font-size: 12px; }
-.ac-skill-value { font-size: 12px; font-family: monospace; }
-.ac-skill-placeholder { color: #5a5248; font-style: italic; padding: 8px; }
-
-.ac-allegiance {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-.ac-alleg-row {
- display: flex;
- justify-content: space-between;
- padding: 2px 8px;
-}
-.ac-alleg-row span:first-child { color: #7a6e5e; }
-.ac-alleg-row span:last-child { color: #c8b89a; }
-
-.ac-footer {
- padding: 8px 15px;
- border-top: 1px solid #8b7355;
- background: rgba(26, 20, 16, 0.8);
-}
-.ac-footer-row {
- display: flex;
- justify-content: space-between;
- padding: 2px 0;
- font-size: 12px;
-}
-.ac-footer-row span:first-child { color: #7a6e5e; }
-.ac-footer-row span:last-child { color: #d4a843; }
+/* 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: #2a2218;
- color: #d4a843;
- border: 1px solid #8b7355;
+ background: #000022;
+ color: #af7a30;
+ border: 1px solid #af7a30;
padding: 2px 6px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
.char-btn:hover {
- background: #3a3228;
- border-color: #d4a843;
+ background: rgba(0, 100, 0, 0.4);
+ border-color: #af7a30;
}