From 176fb020ec1cb7ca6324316d4d2047013d69460d Mon Sep 17 00:00:00 2001 From: erik Date: Fri, 27 Feb 2026 14:39:20 +0000 Subject: [PATCH] Redesign character window to match TreeStats layout and style Replace the AC stone-themed single-scroll character window with a TreeStats- style tabbed interface. Two side-by-side tab containers: left (Attributes, Skills, Titles) and right (Augmentations, Ratings, Other), plus an Allegiance section below. Exact TreeStats color palette (#000022 bg, #af7a30 gold borders, purple specialized, teal trained). Backend accepts new properties and titles fields in character_stats message for JSONB storage. Co-Authored-By: Claude Opus 4.6 --- main.py | 248 ++++++++++++++++++++++++ static/script.js | 492 ++++++++++++++++++++++++++++++++++------------- static/style.css | 336 ++++++++++++++++++-------------- 3 files changed, 797 insertions(+), 279 deletions(-) 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 = ` -
-
-
${name}
-
Awaiting character data...
-
-
-
Attributes
-
-
-
Strength\u2014
-
Quickness\u2014
-
-
-
Endurance\u2014
-
Focus\u2014
-
-
-
Coordination\u2014
-
Self\u2014
+
+

${name}

+
Awaiting character data...
+
+
+
Total XP: \u2014
+
Unassigned XP: \u2014
+
Luminance: \u2014
+
Deaths: \u2014
+
+
+
+
+
Attributes
+
Skills
+
Titles
+
+
+
+
+ Health +
+ \u2014 / \u2014 +
+
+ Stamina +
+ \u2014 / \u2014 +
+
+ Mana +
+ \u2014 / \u2014 +
+ + + + + + + + +
AttributeCreationBase
Strength\u2014\u2014
Endurance\u2014\u2014
Coordination\u2014\u2014
Quickness\u2014\u2014
Focus\u2014\u2014
Self\u2014\u2014
+ + + + + +
VitalBase
Health\u2014
Stamina\u2014
Mana\u2014
+ + +
Skill Credits\u2014
+
+
+
Awaiting data...
+
+
+
Awaiting data...
-
-
Vitals
-
-
- Health -
- \u2014 / \u2014 -
-
- Stamina -
- \u2014 / \u2014 -
-
- Mana -
- \u2014 / \u2014 -
+
+
+
Augmentations
+
Ratings
+
Other
+
+
+
Awaiting data...
+
+
+
Awaiting data...
+
+
+
Awaiting data...
-
-
Skills
-
-
Awaiting data...
-
-
-
-
Allegiance
-
-
Awaiting data...
-
-
- +
+
+
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 += ''; + if (grouped.Specialized.length) { + for (const s of grouped.Specialized) { + html += ``; } } - 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 += `
`; } - html += ``; } - skillsDiv.innerHTML = html; + html += '
SkillLevel
${s.name}${s.base}
${s.name}${s.base}
'; + 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 += ''; + for (const a of augRows) html += ``; + html += '
NameUses
${a.name}${a.uses}
'; + } + if (auraRows.length) { + html += '
Auras
'; + html += ''; + for (const a of auraRows) html += ``; + html += '
NameUses
${a.name}${a.uses}
'; + } + 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 = ''; + for (const r of rows) html += ``; + html += '
RatingValue
${r.name}${r.value}
'; + 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 += ``; + html += '
${r.name}${r.value}
'; + } + + // 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 += ``; + html += '
${m.name}${m.value}
'; + } + + // 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 += ``; + html += '
${s.name}${s.rank} (${s.value})
'; + } + + 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 += ``; + if (a.monarch) html += ``; + if (a.patron) html += ``; + if (a.rank !== undefined) html += ``; + if (a.followers !== undefined) html += ``; + html += '
Name${a.name}
Monarch${a.monarch.name || '\u2014'}
Patron${a.patron.name || '\u2014'}
Rank${a.rank}
Followers${a.followers}
'; + 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; }