diff --git a/_datafiles/html/public/static/css/windows.css b/_datafiles/html/public/static/css/windows.css index b9e376f2b..60210b7b2 100644 --- a/_datafiles/html/public/static/css/windows.css +++ b/_datafiles/html/public/static/css/windows.css @@ -10,9 +10,10 @@ overflow: hidden; } -/* Hide maximize, fullscreen, and close controls — windows are managed via settings */ +/* Hide maximize, fullscreen, minimize, and close controls — windows are managed via settings */ .wb-max { display: none; } .wb-full { display: none; } +.wb-min { display: none; } .wb-close { display: none; } /* ------------------------------------------------------------------------- @@ -164,7 +165,7 @@ opacity: 0.8; } .wb-dock-btn::after { - content: '\2913'; /* downwards arrow to bar - visually suggests docking */ + content: '\25A3'; /* white square containing black square — embed into panel */ } .wb-dock-btn:hover { opacity: 1; } diff --git a/_datafiles/html/public/static/js/webclient-core.js b/_datafiles/html/public/static/js/webclient-core.js index cf7ed84b1..e66d402a7 100644 --- a/_datafiles/html/public/static/js/webclient-core.js +++ b/_datafiles/html/public/static/js/webclient-core.js @@ -174,7 +174,7 @@ class DockSlot { const popoutBtn = document.createElement('span'); popoutBtn.className = 'dock-panel-popout'; popoutBtn.title = 'Pop out'; - popoutBtn.textContent = this.side === 'left' ? '\u2197' : '\u2196'; // NE / NW arrow + popoutBtn.textContent = '⧉'; popoutBtn.addEventListener('click', onPopout); titlebar.appendChild(titleSpan); diff --git a/_datafiles/html/public/static/js/windows/window-character.js b/_datafiles/html/public/static/js/windows/window-character.js index 40b23835a..414c9635b 100644 --- a/_datafiles/html/public/static/js/windows/window-character.js +++ b/_datafiles/html/public/static/js/windows/window-character.js @@ -1,4 +1,4 @@ -/* global Client, VirtualWindow, VirtualWindows, injectStyles, uiMenu */ +/* global Client, VirtualWindow, VirtualWindows, injectStyles */ /** * window-character.js @@ -6,33 +6,28 @@ * Virtual window: Character — left dock, tabbed. * * Tabs: - * Overview — name, race/class, level, alignment, stats grid, - * HP/MP bars, equipment slots (with hover tooltips) - * Backpack — carried items with carry capacity, hover tooltips - * Quests — in-progress quest log, click to expand - * Skills — learned skills with levels and max indicator - * Jobs — profession completion and proficiency + * Overview — name, race/class, level, alignment, stats grid, point badges + * Quests — in-progress quest log, click to expand + * Skills — learned skills with levels and max indicator + * Jobs — profession completion and proficiency + * Effects — active buffs/debuffs with duration bars * * Responds to GMCP namespaces: - * Char — full character update - * Char.Info — name, race, class, level, alignment - * Char.Vitals — HP / MP - * Char.Stats — six core stats - * Char.Inventory — worn equipment + backpack - * Char.Inventory.Backpack — backpack items only - * Char.Quests — quest progress - * Char.Skills — skill names, levels, max flag - * Char.Jobs — profession completion and proficiency + * Char — full character update + * Char.Info — name, race, class, level, alignment, skill/training points + * Char.Stats — six core stats + * Char.Quests — quest progress + * Char.Skills — skill names, levels, max flag + * Char.Jobs — profession completion and proficiency + * Char.Affects — active buffs/debuffs * * Reads: * Client.GMCPStructs.Char.Info - * Client.GMCPStructs.Char.Vitals * Client.GMCPStructs.Char.Stats - * Client.GMCPStructs.Char.Inventory.Worn - * Client.GMCPStructs.Char.Inventory.Backpack * Client.GMCPStructs.Char.Quests * Client.GMCPStructs.Char.Skills * Client.GMCPStructs.Char.Jobs + * Client.GMCPStructs.Char.Affects */ 'use strict'; @@ -136,72 +131,62 @@ .cw-align-neutral { color: #666; } .cw-align-evil { color: #e06060; } - - /* ---- Equipment section (inside Overview) ---- */ - #cw-equip-section { - display: flex; - flex-direction: column; - gap: 2px; - margin-top: 4px; - padding-top: 6px; + /* ---- Stats grid (inside Overview) ---- */ + #cw-stats-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 3px 6px; + padding: 4px 0 2px; border-top: 1px solid #0f3333; + border-bottom: 1px solid #0f3333; } - .cw-equip-row { + /* ---- Points row (below stats grid) ---- */ + #cw-points-row { display: flex; - align-items: center; gap: 6px; - min-height: 18px; - border-bottom: 1px solid #0a1a16; - padding-bottom: 2px; - cursor: default; + padding: 4px 0 2px; + border-bottom: 1px solid #0f3333; } - .cw-equip-row:last-child { border-bottom: none; } + .cw-point-badge { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; + padding: 2px 6px; + background: #0d2e28; + border: 1px solid #1c6b60; + border-radius: 3px; + cursor: help; + gap: 4px; + } - .cw-equip-row:hover { background: #0a1e1a; } + .cw-point-badge:hover { + background: #0f3333; + } - .cw-equip-slot { - width: 54px; - font-size: 0.66em; + .cw-point-badge-label { + font-size: 0.62em; color: #7ab8a0; text-transform: uppercase; - letter-spacing: 0.03em; - flex-shrink: 0; + letter-spacing: 0.04em; + white-space: nowrap; } - .cw-equip-name { - flex: 1; - font-size: 0.76em; + .cw-point-badge-value { + font-size: 0.8em; color: #dffbd1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + font-weight: bold; } - .cw-equip-name.empty { color: #2a2a2a; font-style: italic; } - .cw-equip-name.cursed { color: #e06060; } - .cw-equip-name.quest { color: #d4a843; } - - .cw-equip-badge { - font-size: 0.58em; - padding: 1px 3px; - border-radius: 3px; - flex-shrink: 0; + .cw-point-badge.has-points { + border-color: #3ad4b8; + background: #0d3d35; } - .cw-equip-badge.cursed { background:#3d0f0f; color:#e06060; border:1px solid #6b1c1c; } - .cw-equip-badge.quest { background:#2e2000; color:#d4a843; border:1px solid #6b5010; } - .cw-equip-badge.uses { background:#1a1a2e; color:#9ab0d4; border:1px solid #2e4a6b; } - - /* ---- Stats grid (inside Overview, above vitals) ---- */ - #cw-stats-grid { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: 3px 6px; - padding: 4px 0 2px; - border-top: 1px solid #0f3333; - border-bottom: 1px solid #0f3333; + .cw-point-badge.has-points .cw-point-badge-value { + color: #3ad4b8; } .cw-stat-cell { @@ -254,79 +239,6 @@ display: none; } - /* ---- Equipment tooltip ---- */ - #cw-equip-tooltip { - position: fixed; - z-index: 99999; - pointer-events: none; - background: #0d2e28; - border: 1px solid #1c6b60; - border-radius: 6px; - box-shadow: 0 4px 16px rgba(0,0,0,0.7); - padding: 8px 10px; - min-width: 160px; - max-width: 260px; - display: none; - } - - .cw-tt-name { - font-size: 0.85em; - font-weight: bold; - color: #dffbd1; - margin-bottom: 4px; - line-height: 1.3; - } - - .cw-tt-name .cw-tt-details { - font-weight: normal; - font-style: italic; - color: #7ab8a0; - } - - .cw-tt-name .cw-tt-details.cursed { color: #e06060; } - .cw-tt-name .cw-tt-details.quest { color: #d4a843; } - - .cw-tt-divider { - border: none; - border-top: 1px solid #1c6b60; - margin: 5px 0; - } - - .cw-tt-row { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 8px; - font-size: 0.75em; - line-height: 1.6; - } - - .cw-tt-row-label { - color: #7ab8a0; - text-transform: uppercase; - letter-spacing: 0.04em; - font-size: 0.88em; - flex-shrink: 0; - } - - .cw-tt-row-value { - color: #dffbd1; - text-align: right; - } - - .cw-tt-hint { - font-size: 0.73em; - color: #7ab8a0; - line-height: 1.4; - font-style: italic; - } - - .cw-tt-hint .cw-tt-cmd { - font-style: normal; - color: #3ad4b8; - font-weight: bold; - } - /* ---- Quests tab ---- */ #cw-quests { padding: 4px 6px; @@ -436,107 +348,6 @@ display: block; } - /* ---- Backpack tab ---- */ - #cw-backpack { - padding: 4px 6px; - gap: 3px; - } - - #cw-bp-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 3px 2px 5px; - border-bottom: 1px solid #0f3333; - margin-bottom: 2px; - flex-shrink: 0; - } - - #cw-bp-title { - font-size: 0.68em; - color: #7ab8a0; - text-transform: uppercase; - letter-spacing: 0.04em; - } - - #cw-bp-count { - font-size: 0.68em; - color: #aaa; - } - - #cw-bp-count .bp-count-num { - color: #dffbd1; - } - - #cw-bp-count .bp-count-num.full { - color: #e06060; - } - - #cw-bp-list { - display: flex; - flex-direction: column; - gap: 2px; - flex: 1; - } - - .cw-bp-empty { - color: #444; - font-size: 0.78em; - font-style: italic; - text-align: center; - padding: 12px 0; - } - - .cw-bp-row { - display: flex; - align-items: center; - gap: 6px; - min-height: 18px; - border-bottom: 1px solid #0a1a16; - padding-bottom: 2px; - cursor: default; - flex-shrink: 0; - } - - .cw-bp-row:last-child { border-bottom: none; } - - .cw-bp-row:hover { background: #0a1e1a; } - - .cw-bp-type { - width: 54px; - font-size: 0.66em; - color: #7ab8a0; - text-transform: uppercase; - letter-spacing: 0.03em; - flex-shrink: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .cw-bp-name { - flex: 1; - font-size: 0.76em; - color: #dffbd1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .cw-bp-name.cursed { color: #e06060; } - .cw-bp-name.quest { color: #d4a843; } - - .cw-bp-badge { - font-size: 0.58em; - padding: 1px 3px; - border-radius: 3px; - flex-shrink: 0; - } - - .cw-bp-badge.cursed { background:#3d0f0f; color:#e06060; border:1px solid #6b1c1c; } - .cw-bp-badge.quest { background:#2e2000; color:#d4a843; border:1px solid #6b5010; } - .cw-bp-badge.uses { background:#1a1a2e; color:#9ab0d4; border:1px solid #2e4a6b; } - /* ---- Skills tab ---- */ #cw-skills { padding: 4px 6px; @@ -692,68 +503,108 @@ .cjb-bar-fill.complete { background: #d4a843; } - `); - // ----------------------------------------------------------------------- - // Item hint — mirrors the GetLongDescription() logic from items/items.go. - // Returns an HTML string (may contain ) or null. - // ----------------------------------------------------------------------- - function _itemHint(item) { - const type = (item.type || '').toLowerCase(); - const subtype = (item.subtype || '').toLowerCase(); - const details = item.details || []; + /* ---- Effects tab ---- */ + #cw-effects { + padding: 4px 6px; + gap: 4px; + display: grid; + grid-template-columns: 1fr 1fr; + align-content: flex-start; + } - function cmd(name) { - return '' + name + ''; + .cw-affect-empty { + grid-column: 1 / -1; + color: #444; + font-size: 0.76em; + font-style: italic; + text-align: center; + padding: 14px 0; } - if (details.includes('quest')) { - return 'This is a quest item.'; + .cw-affect-item { + background: #0a1e1a; + border: 1px solid #1c6b60; + border-radius: 4px; + padding: 4px 6px; + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + box-sizing: border-box; } - if (type === 'readable') { - return 'You should probably ' + cmd('read') + ' this.'; + + .cw-affect-item.debuff { + border-color: #6b1c1c; + background: #1e0a0a; } - if (subtype === 'drinkable') { - return 'You could probably ' + cmd('drink') + ' this.'; + + .cw-affect-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 4px; } - if (subtype === 'edible') { - return 'You could probably ' + cmd('eat') + ' this.'; + + .cw-affect-name { + font-size: 0.5em; + color: #dffbd1; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - if (type === 'lockpicks') { - return 'These are used with the ' + cmd('picklock') + ' command.'; + + .cw-affect-item.debuff .cw-affect-name { color: #f4a0a0; } + + .cw-affect-source { + font-size: 0.63em; + color: #7ab8a0; + white-space: nowrap; + flex-shrink: 0; } - if (type === 'key') { - return 'When you find the right door, keys are added to your ' + cmd('keyring') + ' automatically.'; + + .cw-affect-item.debuff .cw-affect-source { color: #b87a7a; } + + .cw-affect-mods { + font-size: 0.66em; + color: #7ab8a0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - if (subtype === 'wearable') { - return 'It looks like wearable ' + type + ' equipment.'; + + .cw-affect-item.debuff .cw-affect-mods { color: #b87a7a; } + + .cw-affect-dur-track { + width: 100%; + height: 4px; + background: #1a1a1a; + border-radius: 2px; + overflow: hidden; } - if (type === 'weapon') { - const handsDetail = details.find(d => d.endsWith('-handed')); - const handsText = handsDetail ? handsDetail : '1-handed'; - if (subtype === 'shooting') { - return 'A ' + handsText + ' ranged weapon. Can be fired into adjacent areas. (' + cmd('help shoot') + ')'; - } - if (subtype === 'claws') { - return 'A ' + handsText + ' claws weapon. Can be dual wielded without training.'; - } - return 'A ' + handsText + ' weapon.'; + + .cw-affect-dur-fill { + height: 100%; + border-radius: 2px; + background: #1c6b60; + transition: width 1s linear; } - if (subtype === 'usable') { - return 'You could probably ' + cmd('use') + ' this.'; + + .cw-affect-item.debuff .cw-affect-dur-fill { background: #6b1c1c; } + + .cw-affect-dur-fill.permanent { + background: #3ad4b8; + width: 100% !important; } - return null; - } + + .cw-affect-item.debuff .cw-affect-dur-fill.permanent { background: #d43a3a; } + `); // ----------------------------------------------------------------------- - // Tooltip - // Created lazily on first use so document.body is guaranteed to exist. + // Stat tooltip // ----------------------------------------------------------------------- - let tooltip = null; - let hideTimer = null; - const rowItemData = new Map(); - - let statTooltip = null; + let statTooltip = null; let statHideTimer = null; function ensureStatTooltip() { @@ -768,11 +619,6 @@ clearTimeout(statHideTimer); statTooltip.textContent = text; statTooltip.style.display = 'block'; - positionStatTooltip(el); - } - - function positionStatTooltip(el) { - if (!statTooltip) { return; } const rect = el.getBoundingClientRect(); const ttW = statTooltip.offsetWidth; const ttH = statTooltip.offsetHeight; @@ -783,9 +629,9 @@ left = Math.max(8, left); let top = rect.top; if (top + ttH > vh - 8) { top = vh - ttH - 8; } - top = Math.max(8, top); + statTooltip.style.left = Math.max(8, top) + 'px'; statTooltip.style.left = left + 'px'; - statTooltip.style.top = top + 'px'; + statTooltip.style.top = Math.max(8, top) + 'px'; } function hideStatTooltip() { @@ -793,137 +639,6 @@ statHideTimer = setTimeout(() => { statTooltip.style.display = 'none'; }, 80); } - function ensureTooltip() { - if (tooltip) { return; } - tooltip = document.createElement('div'); - tooltip.id = 'cw-equip-tooltip'; - document.body.appendChild(tooltip); - } - - function showTooltip(rowEl, item) { - ensureTooltip(); - clearTimeout(hideTimer); - - // Build content - const details = (item.details && item.details.length > 0) - ? item.details.join(', ') - : null; - - const detailClass = item.details && item.details.includes('cursed') ? 'cursed' - : item.details && item.details.includes('quest') ? 'quest' - : ''; - - let html = '
' + item.name; - if (details) { - html += ' (' + details + ')'; - } - html += '
'; - - const rows = []; - if (item.type) { rows.push({ label: 'Type', value: item.type }); } - if (item.subtype) { rows.push({ label: 'Subtype', value: item.subtype }); } - if (item.uses > 0){ rows.push({ label: 'Uses', value: item.uses }); } - - if (rows.length > 0) { - html += '
'; - rows.forEach(r => { - html += '
' + - '' + r.label + '' + - '' + r.value + '' + - '
'; - }); - } - - const hint = _itemHint(item); - if (hint) { - html += '
'; - html += '
' + hint + '
'; - } - - tooltip.innerHTML = html; - tooltip.style.display = 'block'; - - positionTooltip(rowEl); - } - - function positionTooltip(rowEl) { - if (!tooltip) { return; } - const rect = rowEl.getBoundingClientRect(); - const ttW = tooltip.offsetWidth; - const ttH = tooltip.offsetHeight; - const vw = window.innerWidth; - const vh = window.innerHeight; - - // Try to place to the right of the row; flip left if it would overflow - let left = rect.right + 8; - if (left + ttW > vw - 8) { - left = rect.left - ttW - 8; - } - left = Math.max(8, left); - - // Align top with the row; shift up if it would overflow the bottom - let top = rect.top; - if (top + ttH > vh - 8) { - top = vh - ttH - 8; - } - top = Math.max(8, top); - - tooltip.style.left = left + 'px'; - tooltip.style.top = top + 'px'; - } - - function hideTooltip() { - if (!tooltip) { return; } - hideTimer = setTimeout(() => { - tooltip.style.display = 'none'; - }, 80); - } - - function attachTooltip(rowEl) { - rowEl.addEventListener('mouseenter', () => { - const item = rowItemData.get(rowEl); - if (item) { showTooltip(rowEl, item); } - }); - rowEl.addEventListener('mouseleave', hideTooltip); - rowEl.addEventListener('mousemove', () => { - if (tooltip.style.display === 'block') { - positionTooltip(rowEl); - } - }); - } - - // ----------------------------------------------------------------------- - // Context menu helpers - // ----------------------------------------------------------------------- - function _equipMenuItems(item) { - if (!item || !item.name) { return null; } - return [ - { label: 'look ' + item.name, cmd: 'look ' + item.name }, - { label: 'remove ' + item.name, cmd: 'remove ' + item.name }, - ]; - } - - function _backpackMenuItems(item) { - if (!item || !item.name) { return null; } - const type = (item.type || '').toLowerCase(); - const subtype = (item.subtype || '').toLowerCase(); - const cmds = [{ label: 'look ' + item.name, cmd: 'look ' + item.name }]; - if (type === 'weapon' || subtype === 'wearable') { - cmds.push({ label: 'equip ' + item.name, cmd: 'equip ' + item.name }); - } else if (subtype === 'edible') { - cmds.push({ label: 'eat ' + item.name, cmd: 'eat ' + item.name }); - } else if (subtype === 'drinkable') { - cmds.push({ label: 'drink ' + item.name, cmd: 'drink ' + item.name }); - } else if (subtype === 'usable') { - cmds.push({ label: 'use ' + item.name, cmd: 'use ' + item.name }); - } else if (subtype === 'throwable') { - cmds.push({ label: 'throw ' + item.name, cmd: 'throw ' + item.name }); - } else if (type === 'readable') { - cmds.push({ label: 'read ' + item.name, cmd: 'read ' + item.name }); - } - return cmds; - } - // ----------------------------------------------------------------------- // Tab switching // ----------------------------------------------------------------------- @@ -952,19 +667,6 @@ { key: 'perception', abbr: 'PER' }, ]; - const EQUIP_SLOTS = [ - { key: 'head', label: 'Head' }, - { key: 'neck', label: 'Neck' }, - { key: 'body', label: 'Body' }, - { key: 'weapon', label: 'Weapon' }, - { key: 'offhand', label: 'Offhand' }, - { key: 'gloves', label: 'Gloves' }, - { key: 'belt', label: 'Belt' }, - { key: 'ring', label: 'Ring' }, - { key: 'legs', label: 'Legs' }, - { key: 'feet', label: 'Feet' }, - ]; - // ----------------------------------------------------------------------- // DOM factory // ----------------------------------------------------------------------- @@ -972,22 +674,22 @@ const cells = STAT_DEFS.map(d => '
' + '' + d.abbr + '' + - '' + + '\u2014' + '' + '
' ).join(''); - return '
' + cells + '
'; - } - - function buildEquipSection() { - const rows = EQUIP_SLOTS.map(s => - '
' + - '' + s.label + '' + - 'empty' + - '' + - '
' - ).join(''); - return '
' + rows + '
'; + const pointsRow = + '
' + + '
' + + 'Skill Pts' + + '\u2014' + + '
' + + '
' + + 'Train Pts' + + '\u2014' + + '
' + + '
'; + return '
' + cells + '
' + pointsRow; } function createDOM() { @@ -996,26 +698,17 @@ el.innerHTML = '
' + '' + - '' + '' + '' + '' + + '' + '
' + '
' + - '
' + - '
Level —
' + + '
\u2014
' + + '
Level \u2014
' + '
' + buildStatsGrid() + - buildEquipSection() + - '
' + - - '
' + - '
' + - 'Carried Items' + - '0 / ' + - '
' + - '
Empty
' + '
' + '
' + @@ -1028,12 +721,15 @@ '
' + '
No job progress
' + + '
' + + + '
' + + '
No active effects
' + '
'; document.body.appendChild(el); makeTabSwitcher(el); - // Attach click listeners and mod tooltips to stat cells STAT_DEFS.forEach(d => { const cell = el.querySelector('.cw-stat-cell:has(#cw-stat-' + d.key + ')'); if (cell) { @@ -1046,17 +742,10 @@ } }); - // Attach tooltip and click-menu listeners to all equipment rows - EQUIP_SLOTS.forEach(s => { - const rowEl = el.querySelector('#cw-eqrow-' + s.key); - if (!rowEl) { return; } - attachTooltip(rowEl); - rowEl.addEventListener('click', function(e) { - const menuItems = _equipMenuItems(rowItemData.get(rowEl)); - if (menuItems) { uiMenu(e, menuItems); } - }); - rowEl.style.cursor = 'pointer'; - }); + const spBadge = el.querySelector('#cw-badge-sp'); + if (spBadge) { spBadge.addEventListener('click', () => Client.GMCPRequest('Help stat-train')); } + const tpBadge = el.querySelector('#cw-badge-tp'); + if (tpBadge) { tpBadge.addEventListener('click', () => Client.GMCPRequest('Help train')); } return el; } @@ -1067,7 +756,7 @@ const win = new VirtualWindow('Character', { dock: 'left', defaultDocked: true, - dockedHeight: 390, + dockedHeight: 200, factory() { const el = createDOM(); return { @@ -1078,7 +767,7 @@ x: 0, y: 0, width: 300, - height: 580, + height: 180, header: 20, bottom: 60, }; @@ -1090,41 +779,51 @@ // ----------------------------------------------------------------------- function updateOverview() { const info = Client.GMCPStructs.Char && Client.GMCPStructs.Char.Info; + if (!info) { return; } - if (info) { - const nameEl = document.getElementById('cw-char-name'); - nameEl.innerHTML = ''; + const nameEl = document.getElementById('cw-char-name'); + nameEl.innerHTML = ''; - const parts = [info.name, info.class].filter(Boolean); + const parts = [info.name, info.class].filter(Boolean); + if (parts.length) { + nameEl.appendChild(document.createTextNode(parts.join(' \u00b7 '))); + } + + if (info.race) { if (parts.length) { - nameEl.appendChild(document.createTextNode(parts.join(' · '))); + nameEl.appendChild(document.createTextNode(' \u00b7 ')); } + const raceSpan = document.createElement('span'); + raceSpan.className = 'cw-char-race'; + raceSpan.textContent = info.race; + raceSpan.addEventListener('click', () => { + Client.GMCPRequest('Help race ' + info.race.toLowerCase()); + }); + nameEl.appendChild(raceSpan); + } - if (info.race) { - if (parts.length) { - nameEl.appendChild(document.createTextNode(' · ')); - } - const raceSpan = document.createElement('span'); - raceSpan.className = 'cw-char-race'; - raceSpan.textContent = info.race; - raceSpan.addEventListener('click', () => { - Client.GMCPRequest('Help race ' + info.race.toLowerCase()); - }); - nameEl.appendChild(raceSpan); - } + if (!nameEl.textContent) { + nameEl.textContent = '\u2014'; + } - if (!nameEl.textContent) { - nameEl.textContent = '—'; - } + document.getElementById('cw-char-level').textContent = info.level ? 'Level ' + info.level : 'Level \u2014'; - document.getElementById('cw-char-level').textContent = info.level ? 'Level ' + info.level : 'Level —'; + const alignEl = document.getElementById('cw-char-alignment'); + alignEl.textContent = info.alignment || ''; + const a = (info.alignment || '').toLowerCase(); + alignEl.className = 'cw-char-alignment ' + + (a.includes('good') ? 'cw-align-good' : a.includes('evil') ? 'cw-align-evil' : 'cw-align-neutral'); - const alignEl = document.getElementById('cw-char-alignment'); - alignEl.textContent = info.alignment || ''; - const a = (info.alignment || '').toLowerCase(); - alignEl.className = 'cw-char-alignment ' + - (a.includes('good') ? 'cw-align-good' : a.includes('evil') ? 'cw-align-evil' : 'cw-align-neutral'); - } + const sp = info.skillpoints || 0; + const tp = info.trainingpoints || 0; + const spEl = document.getElementById('cw-sp'); + const tpEl = document.getElementById('cw-tp'); + const spBadge = document.getElementById('cw-badge-sp'); + const tpBadge = document.getElementById('cw-badge-tp'); + if (spEl) { spEl.textContent = sp; } + if (tpEl) { tpEl.textContent = tp; } + if (spBadge) { spBadge.classList.toggle('has-points', sp > 0); } + if (tpBadge) { tpBadge.classList.toggle('has-points', tp > 0); } } function updateStats() { @@ -1133,7 +832,7 @@ STAT_DEFS.forEach(def => { const el = document.getElementById('cw-stat-' + def.key); - if (el) { el.textContent = stats[def.key] || '—'; } + if (el) { el.textContent = stats[def.key] || '\u2014'; } const mod = stats[def.key + 'mod']; const modEl = document.getElementById('cw-stat-mod-' + def.key); @@ -1148,136 +847,6 @@ }); } - function updateEquipment() { - const inv = Client.GMCPStructs.Char && Client.GMCPStructs.Char.Inventory; - if (!inv || !inv.Worn) { return; } - - const worn = inv.Worn; - EQUIP_SLOTS.forEach(slot => { - const item = worn[slot.key]; - const rowEl = document.getElementById('cw-eqrow-' + slot.key); - const nameEl = document.getElementById('cw-eq-' + slot.key); - const badgeEl = document.getElementById('cw-eqb-' + slot.key); - - if (!item || !item.name) { - nameEl.textContent = 'empty'; - nameEl.className = 'cw-equip-name empty'; - badgeEl.style.display = 'none'; - rowItemData.delete(rowEl); - prevEquipNames[slot.key] = ''; - return; - } - - // Store full item data on the row for the tooltip - rowItemData.set(rowEl, item); - - const isCursed = item.details && item.details.includes('cursed'); - const isQuest = item.details && item.details.includes('quest'); - - nameEl.textContent = item.name; - nameEl.className = 'cw-equip-name' + (isCursed ? ' cursed' : isQuest ? ' quest' : ''); - - if (isCursed) { - badgeEl.textContent = 'cursed'; badgeEl.className = 'cw-equip-badge cursed'; badgeEl.style.display = ''; - } else if (isQuest) { - badgeEl.textContent = 'quest'; badgeEl.className = 'cw-equip-badge quest'; badgeEl.style.display = ''; - } else if (item.uses > 0) { - badgeEl.textContent = item.uses + 'x'; badgeEl.className = 'cw-equip-badge uses'; badgeEl.style.display = ''; - } else { - badgeEl.style.display = 'none'; - } - }); - } - - function updateBackpack() { - const inv = Client.GMCPStructs.Char && Client.GMCPStructs.Char.Inventory; - if (!inv || !inv.Backpack) { return; } - - const bp = inv.Backpack; - const items = bp.items || []; - const summary = bp.Summary || {}; - const count = summary.count !== undefined ? summary.count : items.length; - const max = summary.max || 0; - - // Update carry capacity header - const numEl = document.getElementById('cw-bp-num'); - const maxEl = document.getElementById('cw-bp-max'); - if (numEl) { - numEl.textContent = count; - numEl.classList.toggle('full', max > 0 && count >= max); - } - if (maxEl) { maxEl.textContent = max || '—'; } - - const list = document.getElementById('cw-bp-list'); - if (!list) { return; } - - // Remove old tooltip registrations for rows about to be replaced - list.querySelectorAll('.cw-bp-row').forEach(r => rowItemData.delete(r)); - list.innerHTML = ''; - - if (items.length === 0) { - list.innerHTML = '
Empty
'; - return; - } - - // Sort: quest items first, then cursed, then alphabetical by name - const sorted = [...items].sort((a, b) => { - const aq = a.details && a.details.includes('quest'); - const bq = b.details && b.details.includes('quest'); - if (aq !== bq) { return aq ? -1 : 1; } - const ac = a.details && a.details.includes('cursed'); - const bc = b.details && b.details.includes('cursed'); - if (ac !== bc) { return ac ? -1 : 1; } - return (a.name || '').localeCompare(b.name || ''); - }); - - sorted.forEach(item => { - const isCursed = item.details && item.details.includes('cursed'); - const isQuest = item.details && item.details.includes('quest'); - - const row = document.createElement('div'); - row.className = 'cw-bp-row'; - - const typeEl = document.createElement('span'); - typeEl.className = 'cw-bp-type'; - typeEl.textContent = item.type || ''; - - const nameEl = document.createElement('span'); - nameEl.className = 'cw-bp-name' + (isCursed ? ' cursed' : isQuest ? ' quest' : ''); - nameEl.textContent = item.name || ''; - - const badgeEl = document.createElement('span'); - badgeEl.className = 'cw-bp-badge'; - if (isCursed) { - badgeEl.textContent = 'cursed'; - badgeEl.classList.add('cursed'); - } else if (isQuest) { - badgeEl.textContent = 'quest'; - badgeEl.classList.add('quest'); - } else if (item.uses > 0) { - badgeEl.textContent = item.uses + 'x'; - badgeEl.classList.add('uses'); - } else { - badgeEl.style.display = 'none'; - } - - row.appendChild(typeEl); - row.appendChild(nameEl); - row.appendChild(badgeEl); - list.appendChild(row); - - // Register item data and attach tooltip — same mechanism as equipment - rowItemData.set(row, item); - attachTooltip(row); - row.style.cursor = 'pointer'; - row.addEventListener('click', function(e) { - const menuItems = _backpackMenuItems(rowItemData.get(row)); - if (menuItems) { uiMenu(e, menuItems); } - }); - - }); - } - function updateSkills() { const skillList = Client.GMCPStructs.Char && Client.GMCPStructs.Char.Skills; const panel = document.getElementById('cw-skills'); @@ -1288,9 +857,7 @@ return; } - // Sort alphabetically by name const sorted = [...skillList].sort((a, b) => (a.name || '').localeCompare(b.name || '')); - panel.innerHTML = ''; sorted.forEach(function(skill) { @@ -1341,7 +908,6 @@ return; } - // Sort: highest completion first, then alphabetical const sorted = [...jobs].sort(function(a, b) { if (b.completion !== a.completion) { return b.completion - a.completion; } return (a.name || '').localeCompare(b.name || ''); @@ -1356,7 +922,6 @@ const item = document.createElement('div'); item.className = 'cjb-item' + (complete ? ' complete' : ''); item.style.cursor = 'help'; - item.innerHTML = '
' + '' + (job.name || '') + '' + @@ -1378,12 +943,9 @@ function updateQuests() { const quests = Client.GMCPStructs.Char && Client.GMCPStructs.Char.Quests; - if (!quests) { return; } - - const panel = document.getElementById('cw-quests'); + const panel = document.getElementById('cw-quests'); if (!panel) { return; } - // Preserve expanded state by quest name const expanded = new Set(); panel.querySelectorAll('.cq-item.expanded').forEach(el => { expanded.add(el.dataset.questName); @@ -1396,7 +958,6 @@ return; } - // Sort: incomplete first (least complete first), completed last, then alphabetical within each group const sorted = [...quests].sort((a, b) => { const ac = (a.completion || 0) >= 100; const bc = (b.completion || 0) >= 100; @@ -1428,16 +989,77 @@ }); } + function _isDebuff(mods) { + if (!mods) { return false; } + return Object.values(mods).some(v => v < 0); + } + + function _formatMods(mods) { + if (!mods || Object.keys(mods).length === 0) { return ''; } + return Object.entries(mods) + .map(([k, v]) => (v >= 0 ? '+' : '') + v + ' ' + k) + .join(' '); + } + + function updateEffects() { + const affects = Client.GMCPStructs.Char && Client.GMCPStructs.Char.Affects; + const panel = document.getElementById('cw-effects'); + if (!panel || !affects) { return; } + + panel.innerHTML = ''; + + const keys = Object.keys(affects); + if (keys.length === 0) { + panel.innerHTML = '
No active effects
'; + return; + } + + keys.sort((a, b) => { + const da = _isDebuff(affects[a].affects); + const db = _isDebuff(affects[b].affects); + if (da !== db) { return da ? 1 : -1; } + const pa = affects[a].duration_max === -1; + const pb = affects[b].duration_max === -1; + if (pa !== pb) { return pa ? 1 : -1; } + return a.localeCompare(b); + }); + + keys.forEach(key => { + const aff = affects[key]; + const debuff = _isDebuff(aff.affects); + const perma = aff.duration_max === -1; + const modText = _formatMods(aff.affects); + + let durPct = 100; + if (!perma && aff.duration_max > 0) { + durPct = Math.max(0, Math.min(100, Math.round((aff.duration_cur / aff.duration_max) * 100))); + } + + const item = document.createElement('div'); + item.className = 'cw-affect-item' + (debuff ? ' debuff' : ''); + item.innerHTML = + '
' + + '' + (aff.name || key) + '' + + '' + (aff.type || '') + '' + + '
' + + (modText ? '
' + modText + '
' : '') + + '
' + + '
' + + '
'; + + panel.appendChild(item); + }); + } + function update() { win.open(); if (!win.isOpen()) { return; } updateOverview(); updateStats(); - updateEquipment(); - updateBackpack(); updateQuests(); updateSkills(); updateJobs(); + updateEffects(); } // ----------------------------------------------------------------------- @@ -1445,7 +1067,7 @@ // ----------------------------------------------------------------------- VirtualWindows.register({ window: win, - gmcpHandlers: ['Char.Info', 'Char.Stats', 'Char.Inventory', 'Char.Inventory.Backpack', 'Char.Quests', 'Char.Skills', 'Char.Jobs', 'Char'], + gmcpHandlers: ['Char.Info', 'Char.Stats', 'Char.Quests', 'Char.Skills', 'Char.Jobs', 'Char.Affects', 'Char'], onGMCP() { update(); }, }); diff --git a/_datafiles/html/public/static/js/windows/window-comm.js b/_datafiles/html/public/static/js/windows/window-comm.js index 923fbfefc..26b9f8aae 100644 --- a/_datafiles/html/public/static/js/windows/window-comm.js +++ b/_datafiles/html/public/static/js/windows/window-comm.js @@ -186,7 +186,7 @@ const win = new VirtualWindow('Communications', { dock: 'right', defaultDocked: true, - dockedHeight: 500, + dockedHeight: 290, factory() { const el = createDOM(); return { diff --git a/_datafiles/html/public/static/js/windows/window-debug-log.js b/_datafiles/html/public/static/js/windows/window-debug-log.js deleted file mode 100644 index c3bdd4765..000000000 --- a/_datafiles/html/public/static/js/windows/window-debug-log.js +++ /dev/null @@ -1,139 +0,0 @@ -/* global Client, VirtualWindow, VirtualWindows, injectStyles */ -/** - * window-debug-log.js - * - * Virtual window: Debug Log - * - * Receives GMCP: - * Comm - incoming channel message - * - * Prints raw structured output to a scrolling monospace log window. - */ - -'use strict'; - -(function() { - - injectStyles(` - #debug-log-output { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - background: #1e1e1e; - color: #cfcfcf; - font-family: monospace; - font-size: 0.75em; - line-height: 1.35em; - overflow: hidden; - } - - #debug-log-stream { - flex: 1; - overflow-y: auto; - padding: 6px; - box-sizing: border-box; - white-space: pre-wrap; - word-break: break-word; - } - - .log-line { - margin: 0; - padding: 0; - } - - .log-text { - color: #dddddd; - white-space: pre-wrap; - - } - `); - - // ----------------------------------------------------------------------- - // DOM - // ----------------------------------------------------------------------- - function createDOM() { - const root = document.createElement('div'); - root.id = 'debug-log-output'; - - const stream = document.createElement('div'); - stream.id = 'debug-log-stream'; - - root.appendChild(stream); - document.body.appendChild(root); - - return root; - } - - // ----------------------------------------------------------------------- - // Window - // ----------------------------------------------------------------------- - const win = new VirtualWindow('Debug Log', { - dock: 'right', - defaultDocked: true, - dockedHeight: 500, - offOnLoad: true, - factory() { - const el = createDOM(); - return { - title: 'Debug Log', - mount: el, - background: '#1e1e1e', - border: 1, - x: 'right', - y: 450, - width: 420, - height: 320, - header: 20, - bottom: 0, - }; - }, - }); - - // ----------------------------------------------------------------------- - // Logging - // ----------------------------------------------------------------------- - function logMessage(namespace) { - const stream = document.getElementById('debug-log-stream'); - if (!stream) return; - - const line = document.createElement('div'); - line.className = 'log-line'; - - const payload = JSON.stringify(Client.GetGMCP(namespace), null, 2); - - line.innerHTML = - `[GMCP] ${namespace} ${payload}`; - - stream.appendChild(line); - - // Auto-scroll - stream.scrollTop = stream.scrollHeight; - - // Trim old logs if too large - while (stream.childElementCount > 500) { - stream.removeChild(stream.firstElementChild); - } - } - - // ----------------------------------------------------------------------- - // GMCP update - // ----------------------------------------------------------------------- - function updateLog(namespace) { - win.open(); - if (!win.isOpen()) return; - logMessage(namespace); - } - - // ----------------------------------------------------------------------- - // Registration - // ----------------------------------------------------------------------- - VirtualWindows.register({ - window: win, - gmcpHandlers: ['*'], - onGMCP(namespace) { - updateLog(namespace); - }, - }); - -})(); diff --git a/_datafiles/html/public/static/js/windows/window-gear.js b/_datafiles/html/public/static/js/windows/window-gear.js new file mode 100644 index 000000000..59daded86 --- /dev/null +++ b/_datafiles/html/public/static/js/windows/window-gear.js @@ -0,0 +1,702 @@ +/* global Client, VirtualWindow, VirtualWindows, injectStyles, uiMenu */ + +/** + * window-gear.js + * + * Virtual window: Gear — left dock, tabbed. + * + * Tabs: + * Worn — equipped items by slot, hover tooltips, click menu + * Backpack — carried items with carry capacity, hover tooltips, click menu + * + * Responds to GMCP namespaces: + * Char.Inventory — worn equipment + backpack + * Char.Inventory.Backpack — backpack items only + * Char — full character update + * + * Reads: + * Client.GMCPStructs.Char.Inventory.Worn + * Client.GMCPStructs.Char.Inventory.Backpack + */ + +'use strict'; + +(function() { + + injectStyles(` + /* ---- shell ---- */ + #gear-window { + height: 100%; + display: flex; + flex-direction: column; + background: #1e1e1e; + } + + /* ---- tab chrome ---- */ + #gear-window .gw-tab-bar { + display: flex; + flex-shrink: 0; + border-bottom: 1px solid #0f3333; + } + + #gear-window .gw-tab-btn { + flex: 1; + padding: 5px 4px; + background: #0d2e28; + border: none; + cursor: pointer; + font: inherit; + font-size: 0.7em; + color: #7ab8a0; + text-transform: uppercase; + letter-spacing: 0.04em; + transition: background 0.15s, color 0.15s; + border-right: 1px solid #0f3333; + } + + #gear-window .gw-tab-btn:last-child { border-right: none; } + + #gear-window .gw-tab-btn:hover { + background: #0f3333; + color: #dffbd1; + } + + #gear-window .gw-tab-btn.active { + background: #1e1e1e; + color: #dffbd1; + border-bottom: 2px solid #3ad4b8; + } + + #gear-window .gw-tab-panel { + display: none; + flex: 1; + overflow-y: auto; + } + + #gear-window .gw-tab-panel::-webkit-scrollbar { width: 4px; } + #gear-window .gw-tab-panel::-webkit-scrollbar-track { background: #111; } + #gear-window .gw-tab-panel::-webkit-scrollbar-thumb { background: #1c6b60; border-radius: 2px; } + + #gear-window .gw-tab-panel.active { + display: flex; + flex-direction: column; + } + + /* ---- Worn tab ---- */ + #gw-worn { + padding: 4px 6px; + gap: 2px; + } + + .gw-equip-row { + display: flex; + align-items: center; + gap: 6px; + min-height: 18px; + border-bottom: 1px solid #0a1a16; + padding-bottom: 2px; + cursor: pointer; + } + + .gw-equip-row:last-child { border-bottom: none; } + + .gw-equip-row:hover { background: #0a1e1a; } + + .gw-equip-slot { + width: 54px; + font-size: 0.66em; + color: #7ab8a0; + text-transform: uppercase; + letter-spacing: 0.03em; + flex-shrink: 0; + } + + .gw-equip-name { + flex: 1; + font-size: 0.76em; + color: #dffbd1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .gw-equip-name.empty { color: #2a2a2a; font-style: italic; } + .gw-equip-name.cursed { color: #e06060; } + .gw-equip-name.quest { color: #d4a843; } + + .gw-equip-badge { + font-size: 0.58em; + padding: 1px 3px; + border-radius: 3px; + flex-shrink: 0; + } + + .gw-equip-badge.cursed { background:#3d0f0f; color:#e06060; border:1px solid #6b1c1c; } + .gw-equip-badge.quest { background:#2e2000; color:#d4a843; border:1px solid #6b5010; } + .gw-equip-badge.uses { background:#1a1a2e; color:#9ab0d4; border:1px solid #2e4a6b; } + + /* ---- Backpack tab ---- */ + #gw-backpack { + padding: 4px 6px; + gap: 3px; + } + + #gw-bp-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 3px 2px 5px; + border-bottom: 1px solid #0f3333; + margin-bottom: 2px; + flex-shrink: 0; + } + + #gw-bp-title { + font-size: 0.68em; + color: #7ab8a0; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + #gw-bp-count { + font-size: 0.68em; + color: #aaa; + } + + #gw-bp-count .gw-bp-count-num { + color: #dffbd1; + } + + #gw-bp-count .gw-bp-count-num.full { + color: #e06060; + } + + #gw-bp-list { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + } + + .gw-bp-empty { + color: #444; + font-size: 0.78em; + font-style: italic; + text-align: center; + padding: 12px 0; + } + + .gw-bp-row { + display: flex; + align-items: center; + gap: 6px; + min-height: 18px; + border-bottom: 1px solid #0a1a16; + padding-bottom: 2px; + cursor: pointer; + flex-shrink: 0; + } + + .gw-bp-row:last-child { border-bottom: none; } + + .gw-bp-row:hover { background: #0a1e1a; } + + .gw-bp-type { + width: 54px; + font-size: 0.66em; + color: #7ab8a0; + text-transform: uppercase; + letter-spacing: 0.03em; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .gw-bp-name { + flex: 1; + font-size: 0.76em; + color: #dffbd1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .gw-bp-name.cursed { color: #e06060; } + .gw-bp-name.quest { color: #d4a843; } + + .gw-bp-badge { + font-size: 0.58em; + padding: 1px 3px; + border-radius: 3px; + flex-shrink: 0; + } + + .gw-bp-badge.cursed { background:#3d0f0f; color:#e06060; border:1px solid #6b1c1c; } + .gw-bp-badge.quest { background:#2e2000; color:#d4a843; border:1px solid #6b5010; } + .gw-bp-badge.uses { background:#1a1a2e; color:#9ab0d4; border:1px solid #2e4a6b; } + + /* ---- Tooltip ---- */ + #gw-item-tooltip { + position: fixed; + z-index: 99999; + pointer-events: none; + background: #0d2e28; + border: 1px solid #1c6b60; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.7); + padding: 8px 10px; + min-width: 160px; + max-width: 260px; + display: none; + } + + .gw-tt-name { + font-size: 0.85em; + font-weight: bold; + color: #dffbd1; + margin-bottom: 4px; + line-height: 1.3; + } + + .gw-tt-details { + font-weight: normal; + font-style: italic; + color: #7ab8a0; + } + + .gw-tt-details.cursed { color: #e06060; } + .gw-tt-details.quest { color: #d4a843; } + + .gw-tt-divider { + border: none; + border-top: 1px solid #1c6b60; + margin: 5px 0; + } + + .gw-tt-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + font-size: 0.75em; + line-height: 1.6; + } + + .gw-tt-row-label { + color: #7ab8a0; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 0.88em; + flex-shrink: 0; + } + + .gw-tt-row-value { + color: #dffbd1; + text-align: right; + } + + .gw-tt-hint { + font-size: 0.73em; + color: #7ab8a0; + line-height: 1.4; + font-style: italic; + } + + .gw-tt-hint .gw-tt-cmd { + font-style: normal; + color: #3ad4b8; + font-weight: bold; + } + `); + + // ----------------------------------------------------------------------- + // Data + // ----------------------------------------------------------------------- + const EQUIP_SLOTS = [ + { key: 'head', label: 'Head' }, + { key: 'neck', label: 'Neck' }, + { key: 'body', label: 'Body' }, + { key: 'weapon', label: 'Weapon' }, + { key: 'offhand', label: 'Offhand' }, + { key: 'gloves', label: 'Gloves' }, + { key: 'belt', label: 'Belt' }, + { key: 'ring', label: 'Ring' }, + { key: 'legs', label: 'Legs' }, + { key: 'feet', label: 'Feet' }, + ]; + + // ----------------------------------------------------------------------- + // Tooltip + // ----------------------------------------------------------------------- + let tooltip = null; + let hideTimer = null; + const rowItemData = new Map(); + + function _itemHint(item) { + const type = (item.type || '').toLowerCase(); + const subtype = (item.subtype || '').toLowerCase(); + const details = item.details || []; + + function cmd(name) { + return '' + name + ''; + } + + if (details.includes('quest')) { return 'This is a quest item.'; } + if (type === 'readable') { return 'You should probably ' + cmd('read') + ' this.'; } + if (subtype === 'drinkable') { return 'You could probably ' + cmd('drink') + ' this.'; } + if (subtype === 'edible') { return 'You could probably ' + cmd('eat') + ' this.'; } + if (type === 'lockpicks') { return 'These are used with the ' + cmd('picklock') + ' command.'; } + if (type === 'key') { return 'When you find the right door, keys are added to your ' + cmd('keyring') + ' automatically.'; } + if (subtype === 'wearable') { return 'It looks like wearable ' + type + ' equipment.'; } + if (type === 'weapon') { + const handsDetail = details.find(d => d.endsWith('-handed')); + const handsText = handsDetail || '1-handed'; + if (subtype === 'shooting') { return 'A ' + handsText + ' ranged weapon. Can be fired into adjacent areas. (' + cmd('help shoot') + ')'; } + if (subtype === 'claws') { return 'A ' + handsText + ' claws weapon. Can be dual wielded without training.'; } + return 'A ' + handsText + ' weapon.'; + } + if (subtype === 'usable') { return 'You could probably ' + cmd('use') + ' this.'; } + return null; + } + + function ensureTooltip() { + if (tooltip) { return; } + tooltip = document.createElement('div'); + tooltip.id = 'gw-item-tooltip'; + document.body.appendChild(tooltip); + } + + function showTooltip(rowEl, item) { + ensureTooltip(); + clearTimeout(hideTimer); + + const details = (item.details && item.details.length > 0) ? item.details.join(', ') : null; + const detailClass = item.details && item.details.includes('cursed') ? 'cursed' + : item.details && item.details.includes('quest') ? 'quest' : ''; + + let html = '
' + item.name; + if (details) { + html += ' (' + details + ')'; + } + html += '
'; + + const rows = []; + if (item.type) { rows.push({ label: 'Type', value: item.type }); } + if (item.subtype) { rows.push({ label: 'Subtype', value: item.subtype }); } + if (item.uses > 0) { rows.push({ label: 'Uses', value: item.uses }); } + + if (rows.length > 0) { + html += '
'; + rows.forEach(r => { + html += '
' + + '' + r.label + '' + + '' + r.value + '' + + '
'; + }); + } + + const hint = _itemHint(item); + if (hint) { + html += '
' + hint + '
'; + } + + tooltip.innerHTML = html; + tooltip.style.display = 'block'; + positionTooltip(rowEl); + } + + function positionTooltip(rowEl) { + if (!tooltip) { return; } + const rect = rowEl.getBoundingClientRect(); + const ttW = tooltip.offsetWidth; + const ttH = tooltip.offsetHeight; + const vw = window.innerWidth; + const vh = window.innerHeight; + let left = rect.right + 8; + if (left + ttW > vw - 8) { left = rect.left - ttW - 8; } + left = Math.max(8, left); + let top = rect.top; + if (top + ttH > vh - 8) { top = vh - ttH - 8; } + tooltip.style.left = left + 'px'; + tooltip.style.top = Math.max(8, top) + 'px'; + } + + function hideTooltip() { + if (!tooltip) { return; } + hideTimer = setTimeout(() => { tooltip.style.display = 'none'; }, 80); + } + + function attachTooltip(rowEl) { + rowEl.addEventListener('mouseenter', () => { + const item = rowItemData.get(rowEl); + if (item) { showTooltip(rowEl, item); } + }); + rowEl.addEventListener('mouseleave', hideTooltip); + rowEl.addEventListener('mousemove', () => { + if (tooltip && tooltip.style.display === 'block') { positionTooltip(rowEl); } + }); + } + + // ----------------------------------------------------------------------- + // Context menu helpers + // ----------------------------------------------------------------------- + function _equipMenuItems(item) { + if (!item || !item.name) { return null; } + return [ + { label: 'look ' + item.name, cmd: 'look ' + item.name }, + { label: 'remove ' + item.name, cmd: 'remove ' + item.name }, + ]; + } + + function _backpackMenuItems(item) { + if (!item || !item.name) { return null; } + const type = (item.type || '').toLowerCase(); + const subtype = (item.subtype || '').toLowerCase(); + const cmds = [{ label: 'look ' + item.name, cmd: 'look ' + item.name }]; + if (type === 'weapon' || subtype === 'wearable') { + cmds.push({ label: 'equip ' + item.name, cmd: 'equip ' + item.name }); + } else if (subtype === 'edible') { + cmds.push({ label: 'eat ' + item.name, cmd: 'eat ' + item.name }); + } else if (subtype === 'drinkable') { + cmds.push({ label: 'drink ' + item.name, cmd: 'drink ' + item.name }); + } else if (subtype === 'usable') { + cmds.push({ label: 'use ' + item.name, cmd: 'use ' + item.name }); + } else if (subtype === 'throwable') { + cmds.push({ label: 'throw ' + item.name, cmd: 'throw ' + item.name }); + } else if (type === 'readable') { + cmds.push({ label: 'read ' + item.name, cmd: 'read ' + item.name }); + } + return cmds; + } + + // ----------------------------------------------------------------------- + // Tab switching + // ----------------------------------------------------------------------- + function makeTabSwitcher(root) { + const btns = root.querySelectorAll('.gw-tab-btn'); + const panels = root.querySelectorAll('.gw-tab-panel'); + btns.forEach(btn => { + btn.addEventListener('click', () => { + btns.forEach(b => b.classList.remove('active')); + panels.forEach(p => p.classList.remove('active')); + btn.classList.add('active'); + root.querySelector('#' + btn.dataset.panel).classList.add('active'); + }); + }); + } + + // ----------------------------------------------------------------------- + // DOM factory + // ----------------------------------------------------------------------- + function buildEquipRows() { + return EQUIP_SLOTS.map(s => + '
' + + '' + s.label + '' + + 'empty' + + '' + + '
' + ).join(''); + } + + function createDOM() { + const el = document.createElement('div'); + el.id = 'gear-window'; + el.innerHTML = + '
' + + '' + + '' + + '
' + + + '
' + + buildEquipRows() + + '
' + + + '
' + + '
' + + 'Carried Items' + + '0 / \u2014' + + '
' + + '
Empty
' + + '
'; + + document.body.appendChild(el); + makeTabSwitcher(el); + + EQUIP_SLOTS.forEach(s => { + const rowEl = el.querySelector('#gw-eqrow-' + s.key); + if (!rowEl) { return; } + attachTooltip(rowEl); + rowEl.addEventListener('click', function(e) { + const menuItems = _equipMenuItems(rowItemData.get(rowEl)); + if (menuItems) { uiMenu(e, menuItems); } + }); + }); + + return el; + } + + // ----------------------------------------------------------------------- + // VirtualWindow + // ----------------------------------------------------------------------- + const win = new VirtualWindow('Gear', { + dock: 'left', + defaultDocked: true, + dockedHeight: 270, + factory() { + const el = createDOM(); + return { + title: 'Gear', + mount: el, + background: '#1e1e1e', + border: 1, + x: 0, + y: 0, + width: 300, + height: 280, + header: 20, + bottom: 60, + }; + }, + }); + + // ----------------------------------------------------------------------- + // Update functions + // ----------------------------------------------------------------------- + function updateWorn() { + const inv = Client.GMCPStructs.Char && Client.GMCPStructs.Char.Inventory; + if (!inv || !inv.Worn) { return; } + + const worn = inv.Worn; + EQUIP_SLOTS.forEach(slot => { + const item = worn[slot.key]; + const rowEl = document.getElementById('gw-eqrow-' + slot.key); + const nameEl = document.getElementById('gw-eq-' + slot.key); + const badgeEl = document.getElementById('gw-eqb-' + slot.key); + if (!rowEl || !nameEl || !badgeEl) { return; } + + if (!item || !item.name) { + nameEl.textContent = 'empty'; + nameEl.className = 'gw-equip-name empty'; + badgeEl.style.display = 'none'; + rowItemData.delete(rowEl); + return; + } + + rowItemData.set(rowEl, item); + + const isCursed = item.details && item.details.includes('cursed'); + const isQuest = item.details && item.details.includes('quest'); + + nameEl.textContent = item.name; + nameEl.className = 'gw-equip-name' + (isCursed ? ' cursed' : isQuest ? ' quest' : ''); + + if (isCursed) { + badgeEl.textContent = 'cursed'; badgeEl.className = 'gw-equip-badge cursed'; badgeEl.style.display = ''; + } else if (isQuest) { + badgeEl.textContent = 'quest'; badgeEl.className = 'gw-equip-badge quest'; badgeEl.style.display = ''; + } else if (item.uses > 0) { + badgeEl.textContent = item.uses + 'x'; badgeEl.className = 'gw-equip-badge uses'; badgeEl.style.display = ''; + } else { + badgeEl.style.display = 'none'; + } + }); + } + + function updateBackpack() { + const inv = Client.GMCPStructs.Char && Client.GMCPStructs.Char.Inventory; + if (!inv || !inv.Backpack) { return; } + + const bp = inv.Backpack; + const items = bp.items || []; + const summary = bp.Summary || {}; + const count = summary.count !== undefined ? summary.count : items.length; + const max = summary.max || 0; + + const numEl = document.getElementById('gw-bp-num'); + const maxEl = document.getElementById('gw-bp-max'); + if (numEl) { + numEl.textContent = count; + numEl.classList.toggle('full', max > 0 && count >= max); + } + if (maxEl) { maxEl.textContent = max || '\u2014'; } + + const list = document.getElementById('gw-bp-list'); + if (!list) { return; } + + list.querySelectorAll('.gw-bp-row').forEach(r => rowItemData.delete(r)); + list.innerHTML = ''; + + if (items.length === 0) { + list.innerHTML = '
Empty
'; + return; + } + + const sorted = [...items].sort((a, b) => { + const aq = a.details && a.details.includes('quest'); + const bq = b.details && b.details.includes('quest'); + if (aq !== bq) { return aq ? -1 : 1; } + const ac = a.details && a.details.includes('cursed'); + const bc = b.details && b.details.includes('cursed'); + if (ac !== bc) { return ac ? -1 : 1; } + return (a.name || '').localeCompare(b.name || ''); + }); + + sorted.forEach(item => { + const isCursed = item.details && item.details.includes('cursed'); + const isQuest = item.details && item.details.includes('quest'); + + const row = document.createElement('div'); + row.className = 'gw-bp-row'; + + const typeEl = document.createElement('span'); + typeEl.className = 'gw-bp-type'; + typeEl.textContent = item.type || ''; + + const nameEl = document.createElement('span'); + nameEl.className = 'gw-bp-name' + (isCursed ? ' cursed' : isQuest ? ' quest' : ''); + nameEl.textContent = item.name || ''; + + const badgeEl = document.createElement('span'); + badgeEl.className = 'gw-bp-badge'; + if (isCursed) { + badgeEl.textContent = 'cursed'; badgeEl.classList.add('cursed'); + } else if (isQuest) { + badgeEl.textContent = 'quest'; badgeEl.classList.add('quest'); + } else if (item.uses > 0) { + badgeEl.textContent = item.uses + 'x'; badgeEl.classList.add('uses'); + } else { + badgeEl.style.display = 'none'; + } + + row.appendChild(typeEl); + row.appendChild(nameEl); + row.appendChild(badgeEl); + list.appendChild(row); + + rowItemData.set(row, item); + attachTooltip(row); + row.addEventListener('click', function(e) { + const menuItems = _backpackMenuItems(rowItemData.get(row)); + if (menuItems) { uiMenu(e, menuItems); } + }); + }); + } + + function update() { + win.open(); + if (!win.isOpen()) { return; } + updateWorn(); + updateBackpack(); + } + + // ----------------------------------------------------------------------- + // Registration + // ----------------------------------------------------------------------- + VirtualWindows.register({ + window: win, + gmcpHandlers: ['Char.Inventory', 'Char.Inventory.Backpack', 'Char'], + onGMCP() { update(); }, + }); + +})(); diff --git a/_datafiles/html/public/static/js/windows/window-killstats.js b/_datafiles/html/public/static/js/windows/window-killstats.js new file mode 100644 index 000000000..228109fe4 --- /dev/null +++ b/_datafiles/html/public/static/js/windows/window-killstats.js @@ -0,0 +1,488 @@ +/* global Client, VirtualWindow, VirtualWindows, injectStyles */ + +/** + * window-killstats.js + * + * Virtual window: Kill Stats — right dock, tabbed, off by default. + * + * Tabs: + * Mobs — kill counts by mob name with totals and K/D ratio + * Races — kill counts grouped by race + * Areas — kill counts grouped by zone/area + * PvP — player kill counts with K/D ratio + * + * Responds to GMCP namespace: + * Char.Kills — kill/death stats + * Char — full character update + * + * Reads: Client.GMCPStructs.Char.Kills + * + * Disabled by default (offOnLoad: true). + */ + +'use strict'; + +(function() { + + injectStyles(` + /* ---- shell ---- */ + #ks-window { + height: 100%; + display: flex; + flex-direction: column; + background: #1e1e1e; + } + + /* ---- tab bar ---- */ + #ks-window .ks-tab-bar { + display: flex; + flex-shrink: 0; + border-bottom: 1px solid #0f3333; + } + + #ks-window .ks-tab-btn { + flex: 1; + padding: 5px 4px; + background: #0d2e28; + border: none; + cursor: pointer; + font: inherit; + font-size: 0.7em; + color: #7ab8a0; + text-transform: uppercase; + letter-spacing: 0.04em; + transition: background 0.15s, color 0.15s; + border-right: 1px solid #0f3333; + } + + #ks-window .ks-tab-btn:last-child { border-right: none; } + + #ks-window .ks-tab-btn:hover { + background: #0f3333; + color: #dffbd1; + } + + #ks-window .ks-tab-btn.active { + background: #1e1e1e; + color: #dffbd1; + border-bottom: 2px solid #3ad4b8; + } + + /* ---- tab panels ---- */ + #ks-window .ks-tab-panel { + display: none; + flex: 1; + flex-direction: column; + overflow: hidden; + } + + #ks-window .ks-tab-panel.active { + display: flex; + } + + /* ---- summary bar ---- */ + .ks-summary { + display: flex; + gap: 0; + flex-shrink: 0; + border-bottom: 1px solid #0f3333; + } + + .ks-summary-cell { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 5px 4px; + border-right: 1px solid #0f3333; + } + + .ks-summary-cell:last-child { border-right: none; } + + .ks-summary-label { + font-size: 0.6em; + color: #7ab8a0; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .ks-summary-value { + font-size: 0.9em; + color: #dffbd1; + font-weight: bold; + } + + .ks-summary-value.kd-good { color: #3ad4b8; } + .ks-summary-value.kd-bad { color: #d44a4a; } + .ks-summary-value.kd-even { color: #d4b83a; } + + /* ---- list area ---- */ + .ks-list { + flex: 1; + overflow-y: auto; + } + + .ks-list::-webkit-scrollbar { width: 4px; } + .ks-list::-webkit-scrollbar-track { background: #111; } + .ks-list::-webkit-scrollbar-thumb { background: #1c6b60; border-radius: 2px; } + + /* ---- list rows ---- */ + .ks-row { + display: flex; + align-items: center; + padding: 4px 8px; + border-bottom: 1px solid #151f1d; + gap: 6px; + } + + .ks-row:last-child { border-bottom: none; } + + .ks-row:hover { background: #0d2e28; } + + .ks-row-name { + flex: 1; + font-size: 0.78em; + color: #dffbd1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .ks-row-count { + font-size: 0.78em; + color: #3ad4b8; + font-weight: bold; + width: 36px; + text-align: right; + flex-shrink: 0; + } + + .ks-row-pct { + font-size: 0.72em; + color: #7ab8a0; + width: 38px; + text-align: right; + flex-shrink: 0; + } + + /* ---- bar fill ---- */ + .ks-bar-track { + width: 52px; + height: 5px; + background: #1a1a1a; + border-radius: 3px; + overflow: hidden; + flex-shrink: 0; + } + + .ks-bar-fill { + height: 100%; + border-radius: 3px; + background: linear-gradient(to right, #1c6b60, #3ad4b8); + } + + /* ---- empty state ---- */ + .ks-empty { + padding: 16px 10px; + color: #444; + font-size: 0.76em; + font-style: italic; + text-align: center; + } + + /* ---- col headers ---- */ + .ks-col-header { + display: flex; + align-items: center; + padding: 3px 8px; + background: #111a19; + border-bottom: 1px solid #0f3333; + flex-shrink: 0; + gap: 6px; + } + + .ks-col-header-name { + flex: 1; + font-size: 0.62em; + color: #3a6e5e; + text-transform: uppercase; + letter-spacing: 0.07em; + } + + .ks-col-header-count { + font-size: 0.62em; + color: #3a6e5e; + text-transform: uppercase; + letter-spacing: 0.07em; + width: 36px; + text-align: right; + flex-shrink: 0; + } + + .ks-col-header-pct { + font-size: 0.62em; + color: #3a6e5e; + text-transform: uppercase; + letter-spacing: 0.07em; + width: 38px; + text-align: right; + flex-shrink: 0; + } + + .ks-col-header-bar { + width: 52px; + flex-shrink: 0; + } + `); + + // ----------------------------------------------------------------------- + // Tab switching + // ----------------------------------------------------------------------- + function makeTabSwitcher(root) { + const btns = root.querySelectorAll('.ks-tab-btn'); + const panels = root.querySelectorAll('.ks-tab-panel'); + btns.forEach(function(btn) { + btn.addEventListener('click', function() { + btns.forEach(function(b) { b.classList.remove('active'); }); + panels.forEach(function(p) { p.classList.remove('active'); }); + btn.classList.add('active'); + root.querySelector('#' + btn.dataset.panel).classList.add('active'); + }); + }); + } + + // ----------------------------------------------------------------------- + // DOM factory + // ----------------------------------------------------------------------- + function createDOM() { + const el = document.createElement('div'); + el.id = 'ks-window'; + + el.innerHTML = + '
' + + '' + + '' + + '' + + '' + + '
' + + + /* Mobs tab */ + '
' + + '
' + + '
Kills
' + + '
Deaths
' + + '
K/D
' + + '
' + + '
' + + 'Mob' + + 'Kills' + + '%' + + '' + + '
' + + '
' + + '
' + + + /* Races tab */ + '
' + + '
' + + 'Race' + + 'Kills' + + '%' + + '' + + '
' + + '
' + + '
' + + + /* Areas tab */ + '
' + + '
' + + 'Area' + + 'Kills' + + '%' + + '' + + '
' + + '
' + + '
' + + + /* PvP tab */ + '
' + + '
' + + '
Kills
' + + '
Deaths
' + + '
K/D
' + + '
' + + '
' + + 'Player' + + 'Kills' + + '%' + + '' + + '
' + + '
' + + '
'; + + document.body.appendChild(el); + makeTabSwitcher(el); + return el; + } + + // ----------------------------------------------------------------------- + // VirtualWindow + // ----------------------------------------------------------------------- + const win = new VirtualWindow('KillStats', { + dock: 'right', + defaultDocked: true, + dockedHeight: 260, + offOnLoad: true, + factory() { + const el = createDOM(); + Client.GMCPRequest('Char.Kills'); + requestAnimationFrame(function() { update(); }); + return { + title: 'Kill Stats', + mount: el, + background: '#1e1e1e', + border: 1, + x: 'right', + y: 0, + width: 363, + height: 300, + header: 20, + bottom: 60, + }; + }, + }); + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + function kdClass(ratio) { + if (ratio > 1) { return 'kd-good'; } + if (ratio < 1) { return 'kd-bad'; } + return 'kd-even'; + } + + function fmtKD(ratio) { + if (ratio === undefined || ratio === null) { return '—'; } + return ratio.toFixed(2) + ':1'; + } + + function renderList(listEl, mapObj, total) { + listEl.innerHTML = ''; + + if (!mapObj || Object.keys(mapObj).length === 0) { + listEl.innerHTML = '
No data yet.
'; + return; + } + + const entries = Object.entries(mapObj); + entries.sort(function(a, b) { return b[1] - a[1]; }); + + const maxVal = entries[0][1]; + + entries.forEach(function(entry) { + const name = entry[0]; + const count = entry[1]; + const pct = total > 0 ? (count / total * 100) : 0; + const barW = maxVal > 0 ? Math.round(count / maxVal * 100) : 0; + + const row = document.createElement('div'); + row.className = 'ks-row'; + row.innerHTML = + '' + name + '' + + '' + count + '' + + '' + pct.toFixed(1) + '%' + + '
'; + listEl.appendChild(row); + }); + } + + function renderPvpList(listEl, playersObj, total) { + listEl.innerHTML = ''; + + if (!playersObj || Object.keys(playersObj).length === 0) { + listEl.innerHTML = '
No PvP kills recorded.
'; + return; + } + + const entries = Object.entries(playersObj).map(function(e) { + return [e[0], e[1].count || 0]; + }); + entries.sort(function(a, b) { return b[1] - a[1]; }); + + const maxVal = entries[0][1]; + + entries.forEach(function(entry) { + const name = entry[0]; + const count = entry[1]; + const pct = total > 0 ? (count / total * 100) : 0; + const barW = maxVal > 0 ? Math.round(count / maxVal * 100) : 0; + + const row = document.createElement('div'); + row.className = 'ks-row'; + row.innerHTML = + '' + name + '' + + '' + count + '' + + '' + pct.toFixed(1) + '%' + + '
'; + listEl.appendChild(row); + }); + } + + // ----------------------------------------------------------------------- + // Update + // ----------------------------------------------------------------------- + function update() { + const kills = Client.GMCPStructs.Char && Client.GMCPStructs.Char.Kills; + if (!kills) { return; } + + win.open(); + if (!win.isOpen()) { return; } + + const mob = kills.mob || {}; + const pvp = kills.pvp || {}; + + /* Mob summary */ + const mobTotal = document.getElementById('ks-mob-total'); + const mobDeaths = document.getElementById('ks-mob-deaths'); + const mobKD = document.getElementById('ks-mob-kd'); + if (mobTotal) { mobTotal.textContent = mob.total !== undefined ? mob.total : '0'; } + if (mobDeaths) { mobDeaths.textContent = mob.deaths !== undefined ? mob.deaths : '0'; } + if (mobKD) { + mobKD.textContent = fmtKD(mob.kd_ratio); + mobKD.className = 'ks-summary-value ' + kdClass(mob.kd_ratio); + } + + /* Mob lists */ + const mobList = document.getElementById('ks-mob-list'); + const raceList = document.getElementById('ks-race-list'); + const areaList = document.getElementById('ks-area-list'); + if (mobList) { renderList(mobList, mob.by_name, mob.total); } + if (raceList) { renderList(raceList, mob.by_race, mob.total); } + if (areaList) { renderList(areaList, mob.by_area, mob.total); } + + /* PvP summary */ + const pvpTotal = document.getElementById('ks-pvp-total'); + const pvpDeaths = document.getElementById('ks-pvp-deaths'); + const pvpKD = document.getElementById('ks-pvp-kd'); + if (pvpTotal) { pvpTotal.textContent = pvp.total !== undefined ? pvp.total : '0'; } + if (pvpDeaths) { pvpDeaths.textContent = pvp.deaths !== undefined ? pvp.deaths : '0'; } + if (pvpKD) { + pvpKD.textContent = fmtKD(pvp.kd_ratio); + pvpKD.className = 'ks-summary-value ' + kdClass(pvp.kd_ratio); + } + + /* PvP list */ + const pvpList = document.getElementById('ks-pvp-list'); + if (pvpList) { renderPvpList(pvpList, pvp.players, pvp.total); } + } + + // ----------------------------------------------------------------------- + // Registration + // ----------------------------------------------------------------------- + VirtualWindows.register({ + window: win, + gmcpHandlers: ['Char.Kills', 'Char'], + onGMCP() { update(); }, + }); + +})(); diff --git a/_datafiles/html/public/static/js/windows/window-room.js b/_datafiles/html/public/static/js/windows/window-room.js new file mode 100644 index 000000000..8821ff5e4 --- /dev/null +++ b/_datafiles/html/public/static/js/windows/window-room.js @@ -0,0 +1,573 @@ +/* global Client, VirtualWindow, VirtualWindows, injectStyles, uiMenu */ + +/** + * window-room.js + * + * Virtual window: Room Info — right dock. + * + * Displays the current room's name, area, environment, detail badges, + * exit badges, and contents (NPCs, players, items, containers). + * + * Responds to GMCP namespace: + * Room.Info — full room update (also handles sub-namespace updates) + * + * Reads: Client.GMCPStructs.Room.Info + */ + +'use strict'; + +(function() { + + injectStyles(` + /* ---- shell ---- */ + #room-window { + height: 100%; + display: flex; + flex-direction: column; + background: #161e1d; + overflow: hidden; + } + + /* ---- header ---- */ + #rw-header { + flex-shrink: 0; + padding: 7px 10px 5px; + background: #0d2e28; + border-bottom: 1px solid #0f3333; + } + + #rw-room-name { + font-size: 0.88em; + font-weight: bold; + color: #dffbd1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; + } + + #rw-room-meta { + display: flex; + align-items: center; + gap: 6px; + } + + #rw-area { + font-size: 0.65em; + color: #7ab8a0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; + } + + #rw-env { + font-size: 0.62em; + color: #3a6e5e; + white-space: nowrap; + flex-shrink: 0; + } + + #rw-badges { + display: flex; + flex-wrap: wrap; + gap: 3px; + margin-top: 4px; + } + + .rw-badge { + font-size: 0.58em; + padding: 1px 5px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: bold; + } + + .rw-badge.pvp { background: #3d0f0f; color: #e06060; border: 1px solid #6b1c1c; } + .rw-badge.bank { background: #1a2500; color: #b8d43a; border: 1px solid #4a6010; } + .rw-badge.trainer { background: #00182a; color: #3ab8d4; border: 1px solid #0f4a5a; } + .rw-badge.storage { background: #1a1a00; color: #d4c43a; border: 1px solid #5a5010; } + .rw-badge.ephemeral { background: #1a001a; color: #b83ad4; border: 1px solid #5a1060; } + .rw-badge.character { background: #001a1a; color: #3ad4b8; border: 1px solid #0f6050; } + .rw-badge.root { background: #001a00; color: #3ad460; border: 1px solid #0f5020; } + + /* ---- exits ---- */ + #rw-exits { + padding: 5px 10px 6px; + border-bottom: 1px solid #0f3333; + flex-shrink: 0; + } + + #rw-exits-label { + font-size: 0.6em; + color: #3a6e5e; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 4px; + } + + #rw-exits-list { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + + .rw-exit-badge { + font-size: 0.65em; + padding: 2px 7px; + border-radius: 3px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.04em; + cursor: pointer; + user-select: none; + transition: background 0.12s, color 0.12s; + } + + .rw-exit-badge.open { + background: #0d2e28; + color: #3ad4b8; + border: 1px solid #1c6b60; + } + + .rw-exit-badge.open:hover { + background: #1c6b60; + color: #dffbd1; + } + + .rw-exit-badge.locked { + background: #1e1800; + color: #d4a83a; + border: 1px solid #5a4a10; + } + + .rw-exit-badge.locked:hover { + background: #3a3000; + color: #f0c84a; + } + + .rw-exit-badge.secret { + background: #0a0a0a; + color: #2a4a44; + border: 1px solid #1a2a28; + } + + .rw-exit-badge.secret:hover { + background: #0f1f1c; + color: #3a6e5e; + } + + /* ---- scroll body (exits + contents together) ---- */ + #rw-body { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + } + + #rw-body::-webkit-scrollbar { width: 4px; } + #rw-body::-webkit-scrollbar-track { background: #111; } + #rw-body::-webkit-scrollbar-thumb { background: #1c6b60; border-radius: 2px; } + + .rw-section { + flex-shrink: 0; + } + + .rw-section-header { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px 3px; + background: #111a19; + border-bottom: 1px solid #0f3333; + } + + .rw-section-title { + font-size: 0.62em; + color: #7ab8a0; + text-transform: uppercase; + letter-spacing: 0.07em; + flex: 1; + } + + .rw-section-count { + font-size: 0.6em; + color: #3ad4b8; + font-weight: bold; + background: #0d2e28; + border: 1px solid #1c6b60; + border-radius: 8px; + padding: 0 5px; + min-width: 16px; + text-align: center; + } + + .rw-section-count.zero { + color: #3a5e50; + border-color: #0f3333; + background: transparent; + } + + .rw-section-body { + display: flex; + flex-direction: column; + } + + /* ---- rows ---- */ + .rw-row { + display: flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-bottom: 1px solid #0a1612; + cursor: pointer; + min-height: 20px; + } + + .rw-row:last-child { border-bottom: none; } + + .rw-row:hover { background: #0a1e1a; } + + .rw-row.aggro { background: #1a0808; } + .rw-row.aggro:hover { background: #2a0c0c; } + + .rw-row-name { + flex: 1; + font-size: 0.76em; + color: #dffbd1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .rw-row.aggro .rw-row-name { color: #f4a0a0; } + + .rw-row-adj { + font-size: 0.63em; + color: #3a6e5e; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 80px; + flex-shrink: 0; + } + + .rw-icon { + font-size: 0.7em; + flex-shrink: 0; + line-height: 1; + } + + .rw-icon.quest { color: #d4a843; } + .rw-icon.aggro { color: #e06060; } + .rw-icon.locked { color: #d4a83a; } + .rw-icon.usable { color: #3ab8d4; } + + .rw-empty { + font-size: 0.7em; + color: #2a4a44; + font-style: italic; + padding: 6px 10px; + } + `); + + // ----------------------------------------------------------------------- + // DOM factory + // ----------------------------------------------------------------------- + function buildSection(id, title) { + const section = document.createElement('div'); + section.className = 'rw-section'; + section.id = 'rws-' + id; + + const header = document.createElement('div'); + header.className = 'rw-section-header'; + header.innerHTML = + '' + title + '' + + '0'; + + const body = document.createElement('div'); + body.className = 'rw-section-body'; + body.id = 'rws-body-' + id; + + section.appendChild(header); + section.appendChild(body); + return section; + } + + function createDOM() { + const el = document.createElement('div'); + el.id = 'room-window'; + + el.innerHTML = + '
' + + '
\u2014
' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; + + const body = document.createElement('div'); + body.id = 'rw-body'; + + const exits = document.createElement('div'); + exits.id = 'rw-exits'; + exits.innerHTML = '
Exits
'; + body.appendChild(exits); + + body.appendChild(buildSection('npcs', 'NPCs')); + body.appendChild(buildSection('players', 'Players')); + body.appendChild(buildSection('items', 'Items')); + body.appendChild(buildSection('containers', 'Containers')); + el.appendChild(body); + + document.body.appendChild(el); + return el; + } + + // ----------------------------------------------------------------------- + // VirtualWindow + // ----------------------------------------------------------------------- + const win = new VirtualWindow('RoomInfo', { + dock: 'right', + defaultDocked: true, + dockedHeight: 340, + factory() { + const el = createDOM(); + return { + title: 'Room Info', + mount: el, + background: '#161e1d', + border: 1, + x: 'right', + y: 0, + width: 280, + height: 400, + header: 20, + bottom: 60, + }; + }, + }); + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + const BADGE_LABELS = { + pvp: 'PvP', + bank: 'Bank', + trainer: 'Trainer', + storage: 'Storage', + ephemeral: 'Ephemeral', + character: 'Char Room', + root: 'Zone Root', + }; + + function setSection(id, rows) { + const body = document.getElementById('rws-body-' + id); + const count = document.getElementById('rws-count-' + id); + if (!body || !count) { return; } + + body.innerHTML = ''; + count.textContent = rows.length; + count.classList.toggle('zero', rows.length === 0); + + if (rows.length === 0) { + const empty = document.createElement('div'); + empty.className = 'rw-empty'; + empty.textContent = 'None'; + body.appendChild(empty); + return; + } + + rows.forEach(function(row) { body.appendChild(row); }); + } + + function makeRow(name, opts) { + opts = opts || {}; + const row = document.createElement('div'); + row.className = 'rw-row' + (opts.aggro ? ' aggro' : ''); + + if (opts.aggro) { + const icon = document.createElement('span'); + icon.className = 'rw-icon aggro'; + icon.textContent = '\u2022'; + icon.title = 'aggressive'; + row.appendChild(icon); + } + + if (opts.quest) { + const icon = document.createElement('span'); + icon.className = 'rw-icon quest'; + icon.textContent = '\u25c6'; + icon.title = 'quest'; + row.appendChild(icon); + } + + const nameEl = document.createElement('span'); + nameEl.className = 'rw-row-name'; + nameEl.textContent = name; + row.appendChild(nameEl); + + if (opts.adj && opts.adj.length > 0) { + const adjEl = document.createElement('span'); + adjEl.className = 'rw-row-adj'; + adjEl.textContent = opts.adj.join(', '); + adjEl.title = opts.adj.join(', '); + row.appendChild(adjEl); + } + + if (opts.locked) { + const icon = document.createElement('span'); + icon.className = 'rw-icon locked'; + icon.textContent = '\u{1f512}'; + icon.title = opts.hasKey ? 'locked (have key)' : opts.hasCombo ? 'locked (have combo)' : 'locked'; + row.appendChild(icon); + } + + if (opts.usable) { + const icon = document.createElement('span'); + icon.className = 'rw-icon usable'; + icon.textContent = '\u2699'; + icon.title = 'craftable'; + row.appendChild(icon); + } + + row.addEventListener('click', function(e) { + uiMenu(e, opts.menuItems || [{ label: 'look ' + name, cmd: 'look ' + name }]); + }); + + return row; + } + + // ----------------------------------------------------------------------- + // Update + // ----------------------------------------------------------------------- + function update() { + const room = Client.GMCPStructs.Room && Client.GMCPStructs.Room.Info; + if (!room) { return; } + + win.open(); + if (!win.isOpen()) { return; } + + // Header + const nameEl = document.getElementById('rw-room-name'); + const areaEl = document.getElementById('rw-area'); + const envEl = document.getElementById('rw-env'); + if (nameEl) { nameEl.textContent = room.name || '\u2014'; } + if (areaEl) { areaEl.textContent = room.area || ''; } + if (envEl) { envEl.textContent = room.environment ? '\u00b7 ' + room.environment : ''; } + + // Detail badges + const badgesEl = document.getElementById('rw-badges'); + if (badgesEl) { + badgesEl.innerHTML = ''; + (room.details || []).forEach(function(d) { + if (!BADGE_LABELS[d]) { return; } + const badge = document.createElement('span'); + badge.className = 'rw-badge ' + d; + badge.textContent = BADGE_LABELS[d]; + badge.style.cursor = 'help'; + badge.addEventListener('click', function() { Client.GMCPRequest('Help ' + d); }); + badgesEl.appendChild(badge); + }); + } + + // Exits — flat wrapping badges + const exitsList = document.getElementById('rw-exits-list'); + if (exitsList) { + exitsList.innerHTML = ''; + const exitsV2 = room.exitsv2 || {}; + const exits = room.exits || {}; + + Object.keys(exits).forEach(function(dir) { + const info = exitsV2[dir] || { details: [] }; + const details = info.details || []; + const isLocked = details.includes('locked'); + const isSecret = details.includes('secret'); + + const badge = document.createElement('span'); + badge.className = 'rw-exit-badge ' + (isLocked ? 'locked' : isSecret ? 'secret' : 'open'); + badge.textContent = dir; + + if (isLocked) { + const hints = []; + if (details.includes('player_has_key')) { hints.push('have key'); } + if (details.includes('player_has_pick_combo')) { hints.push('have combo'); } + badge.title = hints.length > 0 ? hints.join(', ') : 'locked'; + } + + badge.addEventListener('click', function() { Client.SendInput(dir); }); + exitsList.appendChild(badge); + }); + } + + // NPCs — look + attack (use id for targeting) + const npcs = (room.Contents && room.Contents.Npcs) || []; + setSection('npcs', npcs.map(function(c) { + const menuItems = [ + { label: 'look ' + c.name, cmd: 'look ' + c.id }, + { label: 'attack ' + c.name, cmd: 'attack ' + c.id }, + ]; + if (c.adjectives && c.adjectives.includes('shop')) { + menuItems.push({ label: 'list ' + c.name, cmd: 'list ' + c.id }); + } + return makeRow(c.name, { + aggro: c.aggro, + quest: c.quest_flag, + adj: c.adjectives, + menuItems: menuItems, + }); + })); + + // Players — look + attack (use id for targeting) + const players = (room.Contents && room.Contents.Players) || []; + setSection('players', players.map(function(c) { + const menuItems = [ + { label: 'look ' + c.name, cmd: 'look ' + c.id }, + { label: 'attack ' + c.name, cmd: 'attack ' + c.id }, + ]; + if (c.adjectives && c.adjectives.includes('shop')) { + menuItems.push({ label: 'list ' + c.name, cmd: 'list ' + c.id }); + } + return makeRow(c.name, { + aggro: c.aggro, + adj: c.adjectives, + menuItems: menuItems, + }); + })); + + // Items — get only (use id for targeting) + const items = (room.Contents && room.Contents.Items) || []; + setSection('items', items.map(function(itm) { + return makeRow(itm.name, { + quest: itm.quest_flag, + menuItems: [{ label: 'get ' + itm.name, cmd: 'get ' + itm.id }], + }); + })); + + // Containers — look only + const containers = (room.Contents && room.Contents.Containers) || []; + setSection('containers', containers.map(function(c) { + return makeRow(c.name, { + locked: c.locked, + hasKey: c.haskey, + hasCombo: c.haspickcombo, + usable: c.usable, + menuItems: [{ label: 'look ' + c.name, cmd: 'look ' + c.name }], + }); + })); + } + + // ----------------------------------------------------------------------- + // Registration + // ----------------------------------------------------------------------- + VirtualWindows.register({ + window: win, + gmcpHandlers: ['Room.Info'], + onGMCP() { update(); }, + }); + +})(); diff --git a/_datafiles/html/public/static/js/windows/window-status.js b/_datafiles/html/public/static/js/windows/window-status.js index d904993fd..c26242d9b 100644 --- a/_datafiles/html/public/static/js/windows/window-status.js +++ b/_datafiles/html/public/static/js/windows/window-status.js @@ -3,20 +3,16 @@ /** * window-status.js * - * Virtual window: Status — right dock, tabbed. + * Virtual window: Worth — left dock. * - * Tabs: - * Worth — XP progress bar, gold (carried + bank), skill/training points - * Effects — active buffs/debuffs with duration bars + * Displays XP progress bar, gold (carried + bank). * * Responds to GMCP namespaces: - * Char.Worth — XP, gold, points - * Char.Affects — active buffs/debuffs - * Char — full character update + * Char.Worth — XP, gold + * Char — full character update * * Reads: * Client.GMCPStructs.Char.Worth - * Client.GMCPStructs.Char.Affects */ 'use strict'; @@ -24,70 +20,22 @@ (function() { injectStyles(` - /* ---- shared tab chrome ---- */ #status-window { height: 100%; display: flex; flex-direction: column; background: #1e1e1e; - } - - #status-window .sw-tab-bar { - display: flex; - flex-shrink: 0; - border-bottom: 1px solid #0f3333; - } - - #status-window .sw-tab-btn { - flex: 1; - padding: 5px 4px; - background: #0d2e28; - border: none; - cursor: pointer; - font: inherit; - font-size: 0.7em; - color: #7ab8a0; - text-transform: uppercase; - letter-spacing: 0.04em; - transition: background 0.15s, color 0.15s; - border-right: 1px solid #0f3333; - } - - #status-window .sw-tab-btn:last-child { border-right: none; } - - #status-window .sw-tab-btn:hover { - background: #0f3333; - color: #dffbd1; - } - - #status-window .sw-tab-btn.active { - background: #1e1e1e; - color: #dffbd1; - border-bottom: 2px solid #3ad4b8; - } - - #status-window .sw-tab-panel { - display: none; - flex: 1; - overflow-y: auto; - } - - #status-window .sw-tab-panel::-webkit-scrollbar { width: 4px; } - #status-window .sw-tab-panel::-webkit-scrollbar-track { background: #111; } - #status-window .sw-tab-panel::-webkit-scrollbar-thumb { background: #1c6b60; border-radius: 2px; } - - #status-window .sw-tab-panel.active { - display: flex; - flex-direction: column; - } - - /* ---- Worth tab ---- */ - #sw-worth { padding: 8px 10px; gap: 8px; justify-content: flex-start; + overflow-y: auto; + box-sizing: border-box; } + #status-window::-webkit-scrollbar { width: 4px; } + #status-window::-webkit-scrollbar-track { background: #111; } + #status-window::-webkit-scrollbar-thumb { background: #1c6b60; border-radius: 2px; } + .sw-xp-section { display: flex; flex-direction: column; @@ -142,154 +90,8 @@ font-size: 0.85em; color: #dffbd1; } - - .sw-points-row { - display: flex; - gap: 8px; - } - - .sw-point-badge { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - padding: 4px 6px; - background: #0d2e28; - border: 1px solid #1c6b60; - border-radius: 4px; - gap: 1px; - } - - .sw-point-badge-label { - font-size: 0.63em; - color: #7ab8a0; - text-transform: uppercase; - letter-spacing: 0.04em; - } - - .sw-point-badge-value { - font-size: 1em; - color: #dffbd1; - font-weight: bold; - } - - .sw-point-badge.has-points { - border-color: #3ad4b8; - background: #0d3d35; - } - - .sw-point-badge.has-points .sw-point-badge-value { - color: #3ad4b8; - } - - /* ---- Effects tab ---- */ - #sw-effects { - padding: 4px 6px; - gap: 4px; - } - - .sw-affect-empty { - color: #444; - font-size: 0.76em; - font-style: italic; - text-align: center; - padding: 14px 0; - } - - .sw-affect-item { - background: #0a1e1a; - border: 1px solid #1c6b60; - border-radius: 4px; - padding: 4px 6px; - display: flex; - flex-direction: column; - gap: 3px; - flex-shrink: 0; - } - - .sw-affect-item.debuff { - border-color: #6b1c1c; - background: #1e0a0a; - } - - .sw-affect-header { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 4px; - } - - .sw-affect-name { - font-size: 0.8em; - color: #dffbd1; - font-weight: bold; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .sw-affect-item.debuff .sw-affect-name { color: #f4a0a0; } - - .sw-affect-source { - font-size: 0.63em; - color: #7ab8a0; - white-space: nowrap; - flex-shrink: 0; - } - - .sw-affect-item.debuff .sw-affect-source { color: #b87a7a; } - - .sw-affect-mods { - font-size: 0.66em; - color: #7ab8a0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .sw-affect-item.debuff .sw-affect-mods { color: #b87a7a; } - - .sw-affect-dur-track { - width: 100%; - height: 4px; - background: #1a1a1a; - border-radius: 2px; - overflow: hidden; - } - - .sw-affect-dur-fill { - height: 100%; - border-radius: 2px; - background: #1c6b60; - transition: width 1s linear; - } - - .sw-affect-item.debuff .sw-affect-dur-fill { background: #6b1c1c; } - - .sw-affect-dur-fill.permanent { - background: #3ad4b8; - width: 100% !important; - } - - .sw-affect-item.debuff .sw-affect-dur-fill.permanent { background: #d43a3a; } `); - // ----------------------------------------------------------------------- - // Tab switching - // ----------------------------------------------------------------------- - function makeTabSwitcher(root) { - const btns = root.querySelectorAll('.sw-tab-btn'); - const panels = root.querySelectorAll('.sw-tab-panel'); - btns.forEach(btn => { - btn.addEventListener('click', () => { - btns.forEach(b => b.classList.remove('active')); - panels.forEach(p => p.classList.remove('active')); - btn.classList.add('active'); - root.querySelector('#' + btn.dataset.panel).classList.add('active'); - }); - }); - } - // ----------------------------------------------------------------------- // DOM factory // ----------------------------------------------------------------------- @@ -297,60 +99,37 @@ const el = document.createElement('div'); el.id = 'status-window'; el.innerHTML = - '
' + - '' + - '' + + '
' + + '
Experience\u2014 / \u2014
' + + '
' + '
' + - - '
' + - '
' + - '
Experience— / —
' + - '
' + - '
' + - '
' + - '
Gold (on hand)
' + - '
Gold (bank)
' + - '
' + - '
' + - '
Skill Points
' + - '
Training Points
' + - '
' + - '
' + - - '
' + - '
No active effects
' + + '
' + + '
Gold (on hand)\u2014
' + + '
Gold (bank)\u2014
' + '
'; document.body.appendChild(el); - makeTabSwitcher(el); - - const spBadge = el.querySelector('#sw-badge-sp'); - spBadge.style.cursor = 'help'; - spBadge.addEventListener('click', () => Client.GMCPRequest('Help stat-train')); - const tpBadge = el.querySelector('#sw-badge-tp'); - tpBadge.style.cursor = 'help'; - tpBadge.addEventListener('click', () => Client.GMCPRequest('Help train')); return el; } // ----------------------------------------------------------------------- // VirtualWindow // ----------------------------------------------------------------------- - const win = new VirtualWindow('Status', { + const win = new VirtualWindow('Worth', { dock: 'left', defaultDocked: true, - dockedHeight: 190, + dockedHeight: 100, factory() { const el = createDOM(); return { - title: 'Status', + title: 'Worth', mount: el, background: '#1e1e1e', border: 1, x: 0, y: 0, width: 363, - height: 260, + height: 140, header: 20, bottom: 60, }; @@ -361,7 +140,7 @@ // Worth update // ----------------------------------------------------------------------- function fmt(n) { - if (n === undefined || n === null) { return '—'; } + if (n === undefined || n === null) { return '\u2014'; } return Number(n).toLocaleString(); } @@ -377,88 +156,15 @@ document.getElementById('sw-xp-text').textContent = fmt(xp) + ' / ' + fmt(tnl); document.getElementById('sw-gold').textContent = fmt(worth.gold_carry); document.getElementById('sw-bank').textContent = fmt(worth.gold_bank); - - const sp = worth.skillpoints || 0; - const tp = worth.trainingpoints || 0; - document.getElementById('sw-sp').textContent = sp; - document.getElementById('sw-tp').textContent = tp; - document.getElementById('sw-badge-sp').classList.toggle('has-points', sp > 0); - document.getElementById('sw-badge-tp').classList.toggle('has-points', tp > 0); - } - - // ----------------------------------------------------------------------- - // Effects update - // ----------------------------------------------------------------------- - function isDebuff(mods) { - if (!mods) { return false; } - return Object.values(mods).some(v => v < 0); - } - - function formatMods(mods) { - if (!mods || Object.keys(mods).length === 0) { return ''; } - return Object.entries(mods) - .map(([k, v]) => (v >= 0 ? '+' : '') + v + ' ' + k) - .join(' '); - } - - function updateEffects() { - const affects = Client.GMCPStructs.Char && Client.GMCPStructs.Char.Affects; - if (!affects) { return; } - - const panel = document.getElementById('sw-effects'); - panel.innerHTML = ''; - - const keys = Object.keys(affects); - if (keys.length === 0) { - panel.innerHTML = '
No active effects
'; - return; - } - - keys.sort((a, b) => { - const da = isDebuff(affects[a].affects); - const db = isDebuff(affects[b].affects); - if (da !== db) { return da ? 1 : -1; } - const pa = affects[a].duration_max === -1; - const pb = affects[b].duration_max === -1; - if (pa !== pb) { return pa ? 1 : -1; } - return a.localeCompare(b); - }); - - keys.forEach(key => { - const aff = affects[key]; - const debuff = isDebuff(aff.affects); - const perma = aff.duration_max === -1; - const modText = formatMods(aff.affects); - - let durPct = 100; - if (!perma && aff.duration_max > 0) { - durPct = Math.max(0, Math.min(100, Math.round((aff.duration_cur / aff.duration_max) * 100))); - } - - const item = document.createElement('div'); - item.className = 'sw-affect-item' + (debuff ? ' debuff' : ''); - item.innerHTML = - '
' + - '' + (aff.name || key) + '' + - '' + (aff.type || '') + '' + - '
' + - (modText ? '
' + modText + '
' : '') + - '
' + - '
' + - '
'; - - panel.appendChild(item); - }); } // ----------------------------------------------------------------------- - // Combined update + // Update // ----------------------------------------------------------------------- function update() { win.open(); if (!win.isOpen()) { return; } updateWorth(); - updateEffects(); } // ----------------------------------------------------------------------- @@ -466,7 +172,7 @@ // ----------------------------------------------------------------------- VirtualWindows.register({ window: win, - gmcpHandlers: ['Char.Worth', 'Char.Affects', 'Char'], + gmcpHandlers: ['Char.Worth', 'Char'], onGMCP() { update(); }, }); diff --git a/_datafiles/html/public/webclient-context.md b/_datafiles/html/public/webclient-context.md index 6cde32046..3e414ea5e 100644 --- a/_datafiles/html/public/webclient-context.md +++ b/_datafiles/html/public/webclient-context.md @@ -14,13 +14,14 @@ static/js/fx.js Visual effects library (FX glob static/js/triggers.js Text-trigger engine (Triggers global) static/js/windows/window-gametime.js Time & Date window (left dock) static/js/windows/window-character.js Character window (left dock) +static/js/windows/window-gear.js Gear window (left dock) static/js/windows/window-vitals.js Vitals window (left dock) -static/js/windows/window-status.js Status window (left dock) +static/js/windows/window-status.js Worth window (left dock) static/js/windows/window-party.js Party window (left dock) static/js/windows/window-map.js Map window (right dock) +static/js/windows/window-room.js Room Info window (right dock) static/js/windows/window-online.js Online Players window (right dock, off by default) static/js/windows/window-comm.js Communications window (right dock) -static/js/windows/window-debug-log.js Debug Log window (right dock) static/js/windows/window-modal.js Help/content modal overlay (global, no dock) static/css/windows.css Shared dock/panel styles ``` @@ -38,9 +39,10 @@ one ` + + + - diff --git a/_datafiles/world/default/keywords.yaml b/_datafiles/world/default/keywords.yaml index 5e967f727..b8b8b6351 100644 --- a/_datafiles/world/default/keywords.yaml +++ b/_datafiles/world/default/keywords.yaml @@ -183,6 +183,7 @@ help-aliases: pvp: ['pk'] about: ['gomud'] stat-train: ['stat train', 'status train', 'stat points'] + train: ['trainer'] # Default aliases for commands # For example: inv -> inventory # They can be command + argument aliases diff --git a/_datafiles/world/default/templates/help/list.md b/_datafiles/world/default/templates/help/list.md index 52182df4b..c1de870f5 100644 --- a/_datafiles/world/default/templates/help/list.md +++ b/_datafiles/world/default/templates/help/list.md @@ -5,6 +5,15 @@ The ~list~ command lists items for sale at any merchants you are visiting. Some ## Usage: ~list~ - This would list whatever the merchant is carrying. + Lists items for sale from all merchants in the room. + + ~list [merchant]~ + Lists items for sale from a specific merchant (mob or player) in the room. + +## Examples: + + ~list~ + ~list blacksmith~ + ~list #14~ Find out more about referring to items by name by typing ~help item-names~. \ No newline at end of file diff --git a/_datafiles/world/empty/keywords.yaml b/_datafiles/world/empty/keywords.yaml index 5e967f727..b8b8b6351 100644 --- a/_datafiles/world/empty/keywords.yaml +++ b/_datafiles/world/empty/keywords.yaml @@ -183,6 +183,7 @@ help-aliases: pvp: ['pk'] about: ['gomud'] stat-train: ['stat train', 'status train', 'stat points'] + train: ['trainer'] # Default aliases for commands # For example: inv -> inventory # They can be command + argument aliases diff --git a/internal/events/eventtypes.go b/internal/events/eventtypes.go index 521deded1..be4ce7e93 100644 --- a/internal/events/eventtypes.go +++ b/internal/events/eventtypes.go @@ -280,12 +280,13 @@ type PlayerDeath struct { func (l PlayerDeath) Type() string { return `PlayerDeath` } type MobDeath struct { - MobId int - InstanceId int - RoomId int - CharacterName string - Level int - PlayerDamage map[int]int + MobId int + InstanceId int + RoomId int + CharacterName string + Level int + PlayerDamage map[int]int + KilledByUsers []int // user IDs of players who contributed damage; empty if killed by non-players } func (l MobDeath) Type() string { return `MobDeath` } @@ -391,3 +392,12 @@ type RedrawPrompt struct { func (l RedrawPrompt) Type() string { return `RedrawPrompt` } func (l RedrawPrompt) UniqueID() string { return `RedrawPrompt-` + strconv.Itoa(l.UserId) } + +// Fired when a player or mob enters or leaves aggro state +type AggroChanged struct { + UserId int // non-zero if a player's aggro state changed + MobInstanceId int // non-zero if a mob's aggro state changed + RoomId int +} + +func (a AggroChanged) Type() string { return `AggroChanged` } diff --git a/internal/hooks/NewRound_DoCombat.go b/internal/hooks/NewRound_DoCombat.go index 33a40a62c..c4cdb5d73 100644 --- a/internal/hooks/NewRound_DoCombat.go +++ b/internal/hooks/NewRound_DoCombat.go @@ -142,6 +142,7 @@ func handlePlayerCombat(evt events.NewRound) (affectedPlayerIds []int, affectedM uRoom.SendText(fmt.Sprintf(`%s flees to the %s exit!`, user.Character.Name, exitName), user.UserId) user.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{UserId: user.UserId, RoomId: user.Character.RoomId}) originRoomId := user.Character.RoomId if err := rooms.MoveToRoom(user.UserId, exitRoomId); err == nil { @@ -242,7 +243,8 @@ func handlePlayerCombat(evt events.NewRound) (affectedPlayerIds []int, affectedM defMob.Character.CancelBuffsWithFlag(buffs.CancelIfCombat) if defMob.Character.Health <= 0 { - defMob.Character.EndAggro() + defMob.Character.EndAggro() + events.AddToQueue(events.AggroChanged{MobInstanceId: defMob.InstanceId, RoomId: defMob.Character.RoomId}) } else if defMob.Character.Aggro == nil { defMob.PreventIdle = true defMob.Command(fmt.Sprintf("attack @%d", user.UserId)) // @ means player @@ -304,12 +306,14 @@ func handlePlayerCombat(evt events.NewRound) (affectedPlayerIds []int, affectedM if !targetFound { user.SendText(`Your target can't be found.`) user.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{UserId: user.UserId, RoomId: user.Character.RoomId}) continue } defRoom := rooms.LoadRoom(defUser.Character.RoomId) if defRoom == nil { user.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{UserId: user.UserId, RoomId: user.Character.RoomId}) continue } @@ -318,6 +322,7 @@ func handlePlayerCombat(evt events.NewRound) (affectedPlayerIds []int, affectedM if defUser.Character.Health < 1 { user.SendText(`Your rage subsides.`) user.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{UserId: user.UserId, RoomId: user.Character.RoomId}) continue } @@ -500,6 +505,7 @@ func handlePlayerCombat(evt events.NewRound) (affectedPlayerIds []int, affectedM if defMob.Character.Health < 1 { user.SendText("Your rage subsides.") user.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{UserId: user.UserId, RoomId: user.Character.RoomId}) continue } @@ -594,9 +600,12 @@ func handlePlayerCombat(evt events.NewRound) (affectedPlayerIds []int, affectedM if user.Character.Health <= 0 || defMob.Character.Health <= 0 { defMob.Character.EndAggro() + events.AddToQueue(events.AggroChanged{MobInstanceId: defMob.InstanceId, RoomId: defMob.Character.RoomId}) user.Character.EndAggro() + events.AddToQueue(events.AggroChanged{UserId: user.UserId, RoomId: user.Character.RoomId}) } else { user.Character.SetAggro(0, defMob.InstanceId, characters.DefaultAttack) + events.AddToQueue(events.AggroChanged{UserId: user.UserId, RoomId: user.Character.RoomId}) } } @@ -637,6 +646,7 @@ func handleMobCombat(evt events.NewRound) (affectedPlayerIds []int, affectedMobI if mobRoom == nil { mob.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{MobInstanceId: mob.InstanceId, RoomId: mob.Character.RoomId}) continue } @@ -764,12 +774,14 @@ func handleMobCombat(evt events.NewRound) (affectedPlayerIds []int, affectedMobI defUser := users.GetByUserId(mob.Character.Aggro.UserId) if defUser == nil || mob.Character.RoomId != defUser.Character.RoomId { mob.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{MobInstanceId: mob.InstanceId, RoomId: mob.Character.RoomId}) continue } defRoom := rooms.LoadRoom(defUser.Character.RoomId) if defRoom == nil { mob.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{MobInstanceId: mob.InstanceId, RoomId: mob.Character.RoomId}) continue } @@ -777,10 +789,9 @@ func handleMobCombat(evt events.NewRound) (affectedPlayerIds []int, affectedMobI if defUser.Character.Health < 1 { mob.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{MobInstanceId: mob.InstanceId, RoomId: mob.Character.RoomId}) continue } - - // Can't see them, can't fight them. if defUser.Character.HasBuffFlag(buffs.Hidden) { continue } @@ -918,9 +929,12 @@ func handleMobCombat(evt events.NewRound) (affectedPlayerIds []int, affectedMobI if mob.Character.Health <= 0 || defUser.Character.Health <= 0 { mob.Character.EndAggro() + events.AddToQueue(events.AggroChanged{MobInstanceId: mob.InstanceId, RoomId: mob.Character.RoomId}) defUser.Character.EndAggro() + events.AddToQueue(events.AggroChanged{UserId: defUser.UserId, RoomId: defUser.Character.RoomId}) } else { mob.Character.SetAggro(defUser.UserId, 0, characters.DefaultAttack) + events.AddToQueue(events.AggroChanged{MobInstanceId: mob.InstanceId, RoomId: mob.Character.RoomId}) } } @@ -933,6 +947,7 @@ func handleMobCombat(evt events.NewRound) (affectedPlayerIds []int, affectedMobI if defMob == nil || mob.Character.RoomId != defMob.Character.RoomId { mob.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{MobInstanceId: mob.InstanceId, RoomId: mob.Character.RoomId}) continue } @@ -942,6 +957,7 @@ func handleMobCombat(evt events.NewRound) (affectedPlayerIds []int, affectedMobI if defMob.Character.Health < 1 { mob.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{MobInstanceId: mob.InstanceId, RoomId: mob.Character.RoomId}) continue } @@ -1043,9 +1059,12 @@ func handleMobCombat(evt events.NewRound) (affectedPlayerIds []int, affectedMobI if mob.Character.Health <= 0 || defMob.Character.Health <= 0 { mob.Character.EndAggro() + events.AddToQueue(events.AggroChanged{MobInstanceId: mob.InstanceId, RoomId: mob.Character.RoomId}) defMob.Character.EndAggro() + events.AddToQueue(events.AggroChanged{MobInstanceId: defMob.InstanceId, RoomId: defMob.Character.RoomId}) } else { mob.Character.SetAggro(0, defMob.InstanceId, characters.DefaultAttack) + events.AddToQueue(events.AggroChanged{MobInstanceId: mob.InstanceId, RoomId: mob.Character.RoomId}) } } diff --git a/internal/hooks/NewRound_IdleMobs.go b/internal/hooks/NewRound_IdleMobs.go index 5f1bf0ebe..8fb95419e 100644 --- a/internal/hooks/NewRound_IdleMobs.go +++ b/internal/hooks/NewRound_IdleMobs.go @@ -69,6 +69,7 @@ func IdleMobs(e events.Event) events.ListenerReturn { if user == nil || user.Character.RoomId != mob.Character.RoomId { mob.Command(`emote mumbles about losing their quarry.`) mob.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{MobInstanceId: mob.InstanceId, RoomId: mob.Character.RoomId}) } } continue diff --git a/internal/mobcommands/attack.go b/internal/mobcommands/attack.go index 4004a5325..097731215 100644 --- a/internal/mobcommands/attack.go +++ b/internal/mobcommands/attack.go @@ -6,6 +6,7 @@ import ( "github.com/GoMudEngine/GoMud/internal/buffs" "github.com/GoMudEngine/GoMud/internal/characters" + "github.com/GoMudEngine/GoMud/internal/events" "github.com/GoMudEngine/GoMud/internal/mobs" "github.com/GoMudEngine/GoMud/internal/rooms" "github.com/GoMudEngine/GoMud/internal/users" @@ -115,6 +116,8 @@ func Attack(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) { mob.Character.SetAggro(attackPlayerId, 0, characters.DefaultAttack) + events.AddToQueue(events.AggroChanged{MobInstanceId: mob.InstanceId, RoomId: mob.Character.RoomId}) + if !isSneaking { u.SendText(fmt.Sprintf(`%s prepares to fight you!`, mob.Character.Name)) @@ -136,6 +139,8 @@ func Attack(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) { mob.Character.SetAggro(0, attackMobInstanceId, characters.DefaultAttack) + events.AddToQueue(events.AggroChanged{MobInstanceId: mob.InstanceId, RoomId: mob.Character.RoomId}) + if !isSneaking { room.SendText( diff --git a/internal/mobcommands/break.go b/internal/mobcommands/break.go index 491bebd02..0c581a676 100644 --- a/internal/mobcommands/break.go +++ b/internal/mobcommands/break.go @@ -3,6 +3,7 @@ package mobcommands import ( "fmt" + "github.com/GoMudEngine/GoMud/internal/events" "github.com/GoMudEngine/GoMud/internal/mobs" "github.com/GoMudEngine/GoMud/internal/rooms" ) @@ -11,6 +12,7 @@ func Break(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) { if mob.Character.Aggro != nil { mob.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{MobInstanceId: mob.InstanceId, RoomId: mob.Character.RoomId}) room.SendText( fmt.Sprintf(`%s breaks off combat.`, mob.Character.Name)) } diff --git a/internal/mobcommands/suicide.go b/internal/mobcommands/suicide.go index 820ab2dda..309ecc6cf 100644 --- a/internal/mobcommands/suicide.go +++ b/internal/mobcommands/suicide.go @@ -79,6 +79,11 @@ func Suicide(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) { mobXP := mob.Character.XPTL(mob.Character.Level - 1) + killedByUsers := make([]int, 0, len(mob.Character.PlayerDamage)) + for uId := range mob.Character.PlayerDamage { + killedByUsers = append(killedByUsers, uId) + } + events.AddToQueue(events.MobDeath{ MobId: int(mob.MobId), InstanceId: mob.InstanceId, @@ -86,6 +91,7 @@ func Suicide(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) { CharacterName: mob.Character.Name, Level: mob.Character.Level, PlayerDamage: mob.Character.PlayerDamage, + KilledByUsers: killedByUsers, }) xpVal := mobXP / 90 @@ -117,6 +123,7 @@ func Suicide(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) { if user.Character.Aggro != nil { if user.Character.Aggro.MobInstanceId == mob.InstanceId { user.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{UserId: user.UserId, RoomId: user.Character.RoomId}) } } diff --git a/internal/usercommands/attack.go b/internal/usercommands/attack.go index 55e008e19..bcd440ff3 100644 --- a/internal/usercommands/attack.go +++ b/internal/usercommands/attack.go @@ -183,9 +183,7 @@ func Attack(rest string, user *users.UserRecord, room *rooms.Room, flags events. user.Character.SetAggro(0, attackMobInstanceId, characters.DefaultAttack) - user.SendText( - fmt.Sprintf(`You prepare to enter into mortal combat with %s.`, m.Character.Name), - ) + events.AddToQueue(events.AggroChanged{UserId: user.UserId, RoomId: user.Character.RoomId}) if !isSneaking { room.SendText( @@ -239,9 +237,7 @@ func Attack(rest string, user *users.UserRecord, room *rooms.Room, flags events. user.Character.SetAggro(attackPlayerId, 0, characters.DefaultAttack) - user.SendText( - fmt.Sprintf(`You prepare to enter into mortal combat with %s.`, p.Character.Name), - ) + events.AddToQueue(events.AggroChanged{UserId: user.UserId, RoomId: user.Character.RoomId}) if !isSneaking { diff --git a/internal/usercommands/break.go b/internal/usercommands/break.go index 513844997..a315e0430 100644 --- a/internal/usercommands/break.go +++ b/internal/usercommands/break.go @@ -12,6 +12,7 @@ func Break(rest string, user *users.UserRecord, room *rooms.Room, flags events.E if user.Character.Aggro != nil { user.Character.Aggro = nil + events.AddToQueue(events.AggroChanged{UserId: user.UserId, RoomId: user.Character.RoomId}) user.SendText(`You break off combat.`) room.SendText( fmt.Sprintf(`%s breaks off combat.`, user.Character.Name), diff --git a/internal/usercommands/flee.go b/internal/usercommands/flee.go index 49546b3fd..4e2d621a2 100644 --- a/internal/usercommands/flee.go +++ b/internal/usercommands/flee.go @@ -14,6 +14,7 @@ func Flee(rest string, user *users.UserRecord, room *rooms.Room, flags events.Ev user.Character.Aggro = &characters.Aggro{} user.Character.Aggro.Type = characters.Flee + events.AddToQueue(events.AggroChanged{UserId: user.UserId, RoomId: user.Character.RoomId}) } return true, nil diff --git a/internal/usercommands/list.go b/internal/usercommands/list.go index 718b49145..e3edff167 100644 --- a/internal/usercommands/list.go +++ b/internal/usercommands/list.go @@ -23,6 +23,16 @@ func List(rest string, user *users.UserRecord, room *rooms.Room, flags events.Ev listedSomething := false + targetPlayerId := 0 + targetMobInstanceId := 0 + if rest != `` { + targetPlayerId, targetMobInstanceId = room.FindByName(rest, rooms.FindMerchant) + if targetPlayerId == 0 && targetMobInstanceId == 0 { + user.SendText("You don't see that here.") + return true, nil + } + } + for _, mobId := range room.GetMobs(rooms.FindMerchant) { mob := mobs.GetInstance(mobId) @@ -30,6 +40,10 @@ func List(rest string, user *users.UserRecord, room *rooms.Room, flags events.Ev continue } + if targetMobInstanceId != 0 && mob.InstanceId != targetMobInstanceId { + continue + } + user.DidTip(`list`, true) /// Run restock routine @@ -388,6 +402,10 @@ func List(rest string, user *users.UserRecord, room *rooms.Room, flags events.Ev continue } + if targetPlayerId != 0 && uid != targetPlayerId { + continue + } + shopUser := users.GetByUserId(uid) if shopUser == nil { continue diff --git a/modules/auctions/files/data-overlays/keywords.yaml b/modules/auctions/files/data-overlays/keywords.yaml index ffbbfc706..ce6171e25 100644 --- a/modules/auctions/files/data-overlays/keywords.yaml +++ b/modules/auctions/files/data-overlays/keywords.yaml @@ -1,6 +1,6 @@ help: command: - shop: + shops: - auction help-aliases: auction: [bid] diff --git a/modules/gmcp/files/datafiles/templates/help/gmcp-char.template b/modules/gmcp/files/datafiles/templates/help/gmcp-char.template index 0e90ca50d..847d794d7 100644 --- a/modules/gmcp/files/datafiles/templates/help/gmcp-char.template +++ b/modules/gmcp/files/datafiles/templates/help/gmcp-char.template @@ -7,13 +7,15 @@ Requesting Char returns the complete payload. Indiv Char.Info Sent on login and whenever core character attributes change. - account string - The player's account (login) username - name string - The character's in-game name - class string - Derived profession/class based on skill ranks - race string - Character race - alignment string - Moral alignment label (e.g. "Good", "Evil") - level number - Current character level - role string - Account role (e.g. "player", "admin") + account string - The player's account (login) username + name string - The character's in-game name + class string - Derived profession/class based on skill ranks + race string - Character race + alignment string - Moral alignment label (e.g. "Good", "Evil") + level number - Current character level + role string - Account role (e.g. "player", "admin") + skillpoints number - Unspent skill points available for training + trainingpoints number - Unspent training points Char.Vitals Sent whenever health or mana values change. @@ -40,14 +42,12 @@ Sent when stats change (e.g. equipment changes, training). perceptionmod number - Modifier applied to perception Char.Worth -Sent when gold, experience, or spendable points change. +Sent when gold or experience changes. - gold_carry number - Gold currently carried - gold_bank number - Gold stored in the bank - skillpoints number - Unspent skill points available for training - trainingpoints number - Unspent training points - xp number - Total experience points earned - tnl number - Experience points needed to reach the next level + gold_carry number - Gold currently carried + gold_bank number - Gold stored in the bank + xp number - Total experience points earned + tnl number - Experience points needed to reach the next level Char.Affects Sent when active buffs or debuffs change. The payload is an object keyed by effect name. @@ -122,4 +122,19 @@ Sent when the player's pet status changes. type string - Pet species or type hunger string - Hunger state (e.g. "full") +Char.Kills +Sent on login and updated whenever a mob or player kill or death is recorded. + + mob.total number - Total number of mob kills + mob.deaths number - Total number of deaths caused by mobs + mob.kd_ratio number - Mob kill/death ratio + mob.by_name object(string->number) - Kill counts keyed by mob name + mob.by_race object(string->number) - Kill counts keyed by race name + mob.by_area object(string->number) - Kill counts keyed by zone/area name + pvp.total number - Total number of unique players killed + pvp.deaths number - Total number of PvP deaths + pvp.kd_ratio number - PvP kill/death ratio + pvp.players object(string->object) - Per-player kill entries keyed by character name + pvp.players[n].count number - Number of times that player was killed + See also: help gmcp, help gmcp-room, help gmcp-party diff --git a/modules/gmcp/gmcp.Char.go b/modules/gmcp/gmcp.Char.go index 93d1a9556..ea6382b2e 100644 --- a/modules/gmcp/gmcp.Char.go +++ b/modules/gmcp/gmcp.Char.go @@ -13,6 +13,7 @@ import ( "github.com/GoMudEngine/GoMud/internal/mudlog" "github.com/GoMudEngine/GoMud/internal/plugins" "github.com/GoMudEngine/GoMud/internal/quests" + "github.com/GoMudEngine/GoMud/internal/races" "github.com/GoMudEngine/GoMud/internal/rooms" "github.com/GoMudEngine/GoMud/internal/skills" "github.com/GoMudEngine/GoMud/internal/users" @@ -50,6 +51,9 @@ func init() { events.RegisterListener(events.Quest{}, g.questProgressHandler, events.Last) + events.RegisterListener(events.MobDeath{}, g.killsChangedHandler) + events.RegisterListener(events.PlayerDeath{}, g.killsChangedHandler) + } type GMCPCharModule struct { @@ -84,6 +88,32 @@ func (g *GMCPCharModule) questProgressHandler(e events.Event) events.ListenerRet return events.Continue } +func (g *GMCPCharModule) killsChangedHandler(e events.Event) events.ListenerReturn { + + var userIds []int + + switch evt := e.(type) { + case events.MobDeath: + userIds = evt.KilledByUsers + case events.PlayerDeath: + userIds = []int{evt.UserId} + default: + return events.Continue + } + + for _, userId := range userIds { + if userId == 0 { + continue + } + events.AddToQueue(GMCPCharUpdate{ + UserId: userId, + Identifier: `Char.Kills`, + }) + } + + return events.Continue +} + func (g *GMCPCharModule) buffTriggeredHandler(e events.Event) events.ListenerReturn { evt, typeOk := e.(events.BuffsTriggered) @@ -242,7 +272,7 @@ func (g *GMCPCharModule) charTrainedHandler(e events.Event) events.ListenerRetur } // Changing equipment might affect stats, inventory, maxhp/maxmp etc - events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Stats, Char.Worth, Char.Vitals, Char.Inventory.Backpack.Summary, Char.Skills, Char.Jobs`}) + events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Info, Char.Stats, Char.Worth, Char.Vitals, Char.Inventory.Backpack.Summary, Char.Skills, Char.Jobs`}) return events.Continue } @@ -344,13 +374,15 @@ func (g *GMCPCharModule) GetCharNode(user *users.UserRecord, gmcpModule string) if all || g.wantsGMCPPayload(`Char.Info`, gmcpModule) { payload.Info = &GMCPCharModule_Payload_Info{ - Account: user.Username, - Name: user.Character.Name, - Class: skills.GetProfession(user.Character.GetAllSkillRanks()), - Race: user.Character.Race(), - Alignment: user.Character.AlignmentName(), - Level: user.Character.Level, - Role: user.Role, + Account: user.Username, + Name: user.Character.Name, + Class: skills.GetProfession(user.Character.GetAllSkillRanks()), + Race: user.Character.Race(), + Alignment: user.Character.AlignmentName(), + Level: user.Character.Level, + Role: user.Role, + SkillPoints: user.Character.StatPoints, + TrainingPoints: user.Character.TrainingPoints, } if !all { @@ -512,12 +544,10 @@ func (g *GMCPCharModule) GetCharNode(user *users.UserRecord, gmcpModule string) if all || g.wantsGMCPPayload(`Char.Worth`, gmcpModule) { payload.Worth = &GMCPCharModule_Payload_Worth{ - Gold: user.Character.Gold, - Bank: user.Character.Bank, - SkillPoints: user.Character.StatPoints, - TrainingPoints: user.Character.TrainingPoints, - TNL: user.Character.XPTL(user.Character.Level), - XP: user.Character.Experience, + Gold: user.Character.Gold, + Bank: user.Character.Bank, + TNL: user.Character.XPTL(user.Character.Level), + XP: user.Character.Experience, } if !all { @@ -663,6 +693,60 @@ func (g *GMCPCharModule) GetCharNode(user *users.UserRecord, gmcpModule string) } } + if all || g.wantsGMCPPayload(`Char.Kills`, gmcpModule) { + + mobKills := map[string]int{} + raceKills := map[string]int{} + areaKills := map[string]int{} + charKills := map[string]GMCPCharModule_Payload_Kills_PvpEntry{} + + totalMobKills := 0 + totalPvpKills := 0 + + for mid, kCt := range user.Character.KD.Kills { + if mobSpec := mobs.GetMobSpec(mobs.MobId(mid)); mobSpec != nil { + totalMobKills += kCt + mobKills[mobSpec.Character.Name] = mobKills[mobSpec.Character.Name] + kCt + if raceInfo := races.GetRace(mobSpec.Character.RaceId); raceInfo != nil { + raceKills[raceInfo.Name] = raceKills[raceInfo.Name] + kCt + } + areaKills[mobSpec.Zone] = areaKills[mobSpec.Zone] + kCt + } + } + + for userIdNameStr, killCount := range user.Character.KD.PlayerKills { + parts := strings.Split(userIdNameStr, `:`) + if len(parts) == 2 { + charKills[parts[1]] = GMCPCharModule_Payload_Kills_PvpEntry{Count: killCount} + } + totalPvpKills++ + } + + mobKDRatio := user.Character.KD.GetMobKDRatio() + pvpKDRatio := user.Character.KD.GetPvpKDRatio() + + payload.Kills = &GMCPCharModule_Payload_Kills{ + Mob: GMCPCharModule_Payload_Kills_Section{ + Total: totalMobKills, + Deaths: user.Character.KD.GetMobDeaths(), + KDRatio: mobKDRatio, + ByName: mobKills, + ByRace: raceKills, + ByArea: areaKills, + }, + Pvp: GMCPCharModule_Payload_Kills_PvpSection{ + Total: totalPvpKills, + Deaths: user.Character.KD.GetPvpDeaths(), + KDRatio: pvpKDRatio, + Players: charKills, + }, + } + + if !all { + return payload.Kills, `Char.Kills` + } + } + // If we reached this point and Char wasn't requested, we have a problem. if !all { mudlog.Error(`gmcp.Char`, `error`, `Bad module requested`, `module`, gmcpModule) @@ -701,19 +785,22 @@ type GMCPCharModule_Payload struct { Pets []GMCPCharModule_Payload_Pet `json:"Pets,omitempty"` Skills []GMCPCharModule_Payload_Skill `json:"Skills,omitempty"` Jobs []GMCPCharModule_Payload_Job `json:"Jobs,omitempty"` + Kills *GMCPCharModule_Payload_Kills `json:"Kills,omitempty"` } // ///////////////// // Char.Info // ///////////////// type GMCPCharModule_Payload_Info struct { - Account string `json:"account,omitempty"` - Name string `json:"name,omitempty"` - Class string `json:"class,omitempty"` - Race string `json:"race,omitempty"` - Alignment string `json:"alignment,omitempty"` - Level int `json:"level,omitempty"` - Role string `json:"role"` + Account string `json:"account,omitempty"` + Name string `json:"name,omitempty"` + Class string `json:"class,omitempty"` + Race string `json:"race,omitempty"` + Alignment string `json:"alignment,omitempty"` + Level int `json:"level,omitempty"` + Role string `json:"role"` + SkillPoints int `json:"skillpoints"` + TrainingPoints int `json:"trainingpoints"` } // ///////////////// @@ -827,12 +914,10 @@ type GMCPCharModule_Payload_Vitals struct { // Char.Worth // ///////////////// type GMCPCharModule_Payload_Worth struct { - Gold int `json:"gold_carry,omitempty"` - Bank int `json:"gold_bank,omitempty"` - SkillPoints int `json:"skillpoints,omitempty"` - TrainingPoints int `json:"trainingpoints,omitempty"` - TNL int `json:"tnl,omitempty"` - XP int `json:"xp,omitempty"` + Gold int `json:"gold_carry,omitempty"` + Bank int `json:"gold_bank,omitempty"` + TNL int `json:"tnl,omitempty"` + XP int `json:"xp,omitempty"` } // ///////////////// @@ -882,3 +967,31 @@ type GMCPCharModule_Payload_Job struct { Completion int `json:"completion"` Proficiency string `json:"proficiency"` } + +// ///////////////// +// Char.Kills +// ///////////////// +type GMCPCharModule_Payload_Kills struct { + Mob GMCPCharModule_Payload_Kills_Section `json:"mob"` + Pvp GMCPCharModule_Payload_Kills_PvpSection `json:"pvp"` +} + +type GMCPCharModule_Payload_Kills_Section struct { + Total int `json:"total"` + Deaths int `json:"deaths"` + KDRatio float64 `json:"kd_ratio"` + ByName map[string]int `json:"by_name"` + ByRace map[string]int `json:"by_race"` + ByArea map[string]int `json:"by_area"` +} + +type GMCPCharModule_Payload_Kills_PvpSection struct { + Total int `json:"total"` + Deaths int `json:"deaths"` + KDRatio float64 `json:"kd_ratio"` + Players map[string]GMCPCharModule_Payload_Kills_PvpEntry `json:"players"` +} + +type GMCPCharModule_Payload_Kills_PvpEntry struct { + Count int `json:"count"` +} diff --git a/modules/gmcp/gmcp.Room.go b/modules/gmcp/gmcp.Room.go index e611e2e62..8c5b69215 100644 --- a/modules/gmcp/gmcp.Room.go +++ b/modules/gmcp/gmcp.Room.go @@ -36,6 +36,8 @@ func init() { events.RegisterListener(events.RoomChange{}, g.roomChangeHandler) events.RegisterListener(events.PlayerDespawn{}, g.despawnHandler) events.RegisterListener(GMCPRoomUpdate{}, g.buildAndSendGMCPPayload) + events.RegisterListener(events.ItemOwnership{}, g.itemOwnershipHandler) + events.RegisterListener(events.AggroChanged{}, g.aggroChangedHandler) } @@ -52,6 +54,65 @@ type GMCPRoomUpdate struct { func (g GMCPRoomUpdate) Type() string { return `GMCPRoomUpdate` } +func (g *GMCPRoomModule) itemOwnershipHandler(e events.Event) events.ListenerReturn { + + evt, typeOk := e.(events.ItemOwnership) + if !typeOk { + return events.Continue + } + + // Only care about player ownership changes — look up their room + if evt.UserId == 0 { + return events.Continue + } + + user := users.GetByUserId(evt.UserId) + if user == nil { + return events.Continue + } + + room := rooms.LoadRoom(user.Character.RoomId) + if room == nil { + return events.Continue + } + + for _, uId := range room.GetPlayers() { + events.AddToQueue(GMCPRoomUpdate{ + UserId: uId, + Identifier: `Room.Info.Contents.Items`, + }) + } + + return events.Continue +} + +func (g *GMCPRoomModule) aggroChangedHandler(e events.Event) events.ListenerReturn { + + evt, typeOk := e.(events.AggroChanged) + if !typeOk { + return events.Continue + } + + room := rooms.LoadRoom(evt.RoomId) + if room == nil { + return events.Continue + } + + identifier := `Room.Info.Contents.Npcs` + if evt.UserId > 0 { + identifier = `Room.Info.Contents.Players` + } + + for _, uId := range room.GetPlayers() { + events.AddToQueue(GMCPRoomUpdate{ + UserId: uId, + Identifier: identifier, + }) + } + + return events.Continue +} + func (g *GMCPRoomModule) despawnHandler(e events.Event) events.ListenerReturn { evt, typeOk := e.(events.PlayerDespawn)