From a69f9604feac02cb45d5abe3f4d278a89ada11b7 Mon Sep 17 00:00:00 2001 From: Volte6 <143822+Volte6@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:08:52 -0700 Subject: [PATCH 1/5] Adding "kill stats" window (off by default). Removing "debug log" --- _datafiles/html/public/static/css/windows.css | 5 +- .../html/public/static/js/webclient-core.js | 2 +- .../public/static/js/windows/window-comm.js | 2 +- .../static/js/windows/window-debug-log.js | 139 ----- .../static/js/windows/window-killstats.js | 488 ++++++++++++++++++ _datafiles/html/public/webclient-context.md | 6 +- _datafiles/html/public/webclient-pure.html | 2 +- internal/events/eventtypes.go | 13 +- internal/mobcommands/suicide.go | 6 + .../templates/help/gmcp-char.template | 15 + modules/gmcp/gmcp.Char.go | 113 ++++ 11 files changed, 637 insertions(+), 154 deletions(-) delete mode 100644 _datafiles/html/public/static/js/windows/window-debug-log.js create mode 100644 _datafiles/html/public/static/js/windows/window-killstats.js 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-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-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/webclient-context.md b/_datafiles/html/public/webclient-context.md index 6cde32046..68e8ff578 100644 --- a/_datafiles/html/public/webclient-context.md +++ b/_datafiles/html/public/webclient-context.md @@ -20,7 +20,6 @@ static/js/windows/window-party.js Party window (left dock) static/js/windows/window-map.js Map 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 ``` @@ -50,7 +49,6 @@ one ` + - diff --git a/internal/events/eventtypes.go b/internal/events/eventtypes.go index 521deded1..1efb61e63 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` } diff --git a/internal/mobcommands/suicide.go b/internal/mobcommands/suicide.go index 820ab2dda..d219f93e6 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 diff --git a/modules/gmcp/files/datafiles/templates/help/gmcp-char.template b/modules/gmcp/files/datafiles/templates/help/gmcp-char.template index 0e90ca50d..b9cc4eaba 100644 --- a/modules/gmcp/files/datafiles/templates/help/gmcp-char.template +++ b/modules/gmcp/files/datafiles/templates/help/gmcp-char.template @@ -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..091eb0561 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) @@ -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,6 +785,7 @@ 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"` } // ///////////////// @@ -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"` +} From 1e263090065749cc5397d58d0add7e868fee006a Mon Sep 17 00:00:00 2001 From: Volte6 <143822+Volte6@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:36:07 -0700 Subject: [PATCH 2/5] Moving "effects" to character window --- .../static/js/windows/window-character.js | 254 ++++++++++++- .../public/static/js/windows/window-status.js | 340 ++---------------- _datafiles/html/public/webclient-context.md | 14 +- .../templates/help/gmcp-char.template | 28 +- modules/gmcp/gmcp.Char.go | 54 +-- 5 files changed, 320 insertions(+), 370 deletions(-) diff --git a/_datafiles/html/public/static/js/windows/window-character.js b/_datafiles/html/public/static/js/windows/window-character.js index 40b23835a..a32bce0bb 100644 --- a/_datafiles/html/public/static/js/windows/window-character.js +++ b/_datafiles/html/public/static/js/windows/window-character.js @@ -7,11 +7,12 @@ * * Tabs: * Overview — name, race/class, level, alignment, stats grid, - * HP/MP bars, equipment slots (with hover tooltips) + * 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 + * Effects — active buffs/debuffs with duration bars * * Responds to GMCP namespaces: * Char — full character update @@ -23,6 +24,7 @@ * 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 @@ -33,6 +35,7 @@ * Client.GMCPStructs.Char.Quests * Client.GMCPStructs.Char.Skills * Client.GMCPStructs.Char.Jobs + * Client.GMCPStructs.Char.Affects */ 'use strict'; @@ -204,6 +207,54 @@ border-bottom: 1px solid #0f3333; } + /* ---- Points row (below stats grid) ---- */ + #cw-points-row { + display: flex; + gap: 6px; + padding: 4px 0 2px; + border-bottom: 1px solid #0f3333; + } + + .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-point-badge:hover { + background: #0f3333; + } + + .cw-point-badge-label { + font-size: 0.62em; + color: #7ab8a0; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + } + + .cw-point-badge-value { + font-size: 0.8em; + color: #dffbd1; + font-weight: bold; + } + + .cw-point-badge.has-points { + border-color: #3ad4b8; + background: #0d3d35; + } + + .cw-point-badge.has-points .cw-point-badge-value { + color: #3ad4b8; + } + .cw-stat-cell { display: flex; justify-content: space-between; @@ -692,6 +743,102 @@ .cjb-bar-fill.complete { background: #d4a843; } + + /* ---- Effects tab ---- */ + #cw-effects { + padding: 4px 6px; + gap: 4px; + display: grid; + grid-template-columns: 1fr 1fr; + align-content: flex-start; + } + + .cw-affect-empty { + grid-column: 1 / -1; + color: #444; + font-size: 0.76em; + font-style: italic; + text-align: center; + padding: 14px 0; + } + + .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; + } + + .cw-affect-item.debuff { + border-color: #6b1c1c; + background: #1e0a0a; + } + + .cw-affect-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 4px; + } + + .cw-affect-name { + font-size: 0.5em; + color: #dffbd1; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .cw-affect-item.debuff .cw-affect-name { color: #f4a0a0; } + + .cw-affect-source { + font-size: 0.63em; + color: #7ab8a0; + white-space: nowrap; + flex-shrink: 0; + } + + .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; + } + + .cw-affect-item.debuff .cw-affect-mods { color: #b87a7a; } + + .cw-affect-dur-track { + width: 100%; + height: 4px; + background: #1a1a1a; + border-radius: 2px; + overflow: hidden; + } + + .cw-affect-dur-fill { + height: 100%; + border-radius: 2px; + background: #1c6b60; + transition: width 1s linear; + } + + .cw-affect-item.debuff .cw-affect-dur-fill { background: #6b1c1c; } + + .cw-affect-dur-fill.permanent { + background: #3ad4b8; + width: 100% !important; + } + + .cw-affect-item.debuff .cw-affect-dur-fill.permanent { background: #d43a3a; } `); // ----------------------------------------------------------------------- @@ -976,7 +1123,18 @@ '' + '' ).join(''); - return '
' + cells + '
'; + const pointsRow = + '
' + + '
' + + 'Skill Pts' + + '' + + '
' + + '
' + + 'Train Pts' + + '' + + '
' + + '
'; + return '
' + cells + '
' + pointsRow; } function buildEquipSection() { @@ -1000,6 +1158,7 @@ '' + '' + '' + + '' + '' + '
' + @@ -1028,6 +1187,10 @@ '
' + '
No job progress
' + + '
' + + + '
' + + '
No active effects
' + '
'; document.body.appendChild(el); @@ -1046,6 +1209,12 @@ } }); + // Attach click handlers to the point badges + 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')); } + // Attach tooltip and click-menu listeners to all equipment rows EQUIP_SLOTS.forEach(s => { const rowEl = el.querySelector('#cw-eqrow-' + s.key); @@ -1124,6 +1293,17 @@ 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); } } } @@ -1428,6 +1608,73 @@ }); } + // ----------------------------------------------------------------------- + // 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; + const panel = document.getElementById('cw-effects'); + if (!panel) { return; } + + if (!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; } @@ -1438,6 +1685,7 @@ updateQuests(); updateSkills(); updateJobs(); + updateEffects(); } // ----------------------------------------------------------------------- @@ -1445,7 +1693,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.Inventory', 'Char.Inventory.Backpack', 'Char.Quests', 'Char.Skills', 'Char.Jobs', 'Char.Affects', 'Char'], 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 68e8ff578..f1e1f8e58 100644 --- a/_datafiles/html/public/webclient-context.md +++ b/_datafiles/html/public/webclient-context.md @@ -15,7 +15,7 @@ static/js/triggers.js Text-trigger engine (Triggers g 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-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-online.js Online Players window (right dock, off by default) @@ -37,9 +37,9 @@ one ` + From e145613dc12d728680bfee4d5521b86c6ed0ef97 Mon Sep 17 00:00:00 2001 From: Volte6 <143822+Volte6@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:13:45 -0700 Subject: [PATCH 4/5] Adding a "Room Info" window --- .../public/static/js/windows/window-room.js | 563 ++++++++++++++++++ _datafiles/html/public/webclient-context.md | 2 + _datafiles/html/public/webclient-pure.html | 1 + 3 files changed, 566 insertions(+) create mode 100644 _datafiles/html/public/static/js/windows/window-room.js 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..371239ddf --- /dev/null +++ b/_datafiles/html/public/static/js/windows/window-room.js @@ -0,0 +1,563 @@ +/* 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]; + 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) { + return makeRow(c.name, { + aggro: c.aggro, + quest: c.quest_flag, + adj: c.adjectives, + menuItems: [ + { label: 'look ' + c.name, cmd: 'look ' + c.id }, + { label: 'attack ' + c.name, cmd: 'attack ' + c.id }, + ], + }); + })); + + // Players — look + attack (use id for targeting) + const players = (room.Contents && room.Contents.Players) || []; + setSection('players', players.map(function(c) { + return makeRow(c.name, { + aggro: c.aggro, + adj: c.adjectives, + menuItems: [ + { label: 'look ' + c.name, cmd: 'look ' + c.id }, + { label: 'attack ' + c.name, cmd: 'attack ' + c.id }, + ], + }); + })); + + // 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/webclient-context.md b/_datafiles/html/public/webclient-context.md index f94e64949..3e414ea5e 100644 --- a/_datafiles/html/public/webclient-context.md +++ b/_datafiles/html/public/webclient-context.md @@ -19,6 +19,7 @@ static/js/windows/window-vitals.js Vitals 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-modal.js Help/content modal overlay (global, no dock) @@ -49,6 +50,7 @@ one ` + From 61fc80f62d8a94123ee80f1074f389dcdb1d1526 Mon Sep 17 00:00:00 2001 From: Volte6 <143822+Volte6@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:47:08 -0700 Subject: [PATCH 5/5] New event type `AggroChanged`, sends GMCP update for the room. `list` can target a player/mob. Room webclient window has better updating and interactivity. --- .../public/static/js/windows/window-room.js | 26 +++++--- _datafiles/world/default/keywords.yaml | 1 + .../world/default/templates/help/list.md | 11 +++- _datafiles/world/empty/keywords.yaml | 1 + internal/events/eventtypes.go | 9 +++ internal/hooks/NewRound_DoCombat.go | 25 +++++++- internal/hooks/NewRound_IdleMobs.go | 1 + internal/mobcommands/attack.go | 5 ++ internal/mobcommands/break.go | 2 + internal/mobcommands/suicide.go | 1 + internal/usercommands/attack.go | 8 +-- internal/usercommands/break.go | 1 + internal/usercommands/flee.go | 1 + internal/usercommands/list.go | 18 ++++++ .../files/data-overlays/keywords.yaml | 2 +- modules/gmcp/gmcp.Room.go | 61 +++++++++++++++++++ 16 files changed, 154 insertions(+), 19 deletions(-) diff --git a/_datafiles/html/public/static/js/windows/window-room.js b/_datafiles/html/public/static/js/windows/window-room.js index 371239ddf..8821ff5e4 100644 --- a/_datafiles/html/public/static/js/windows/window-room.js +++ b/_datafiles/html/public/static/js/windows/window-room.js @@ -469,6 +469,8 @@ 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); }); } @@ -505,27 +507,35 @@ // 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: [ - { label: 'look ' + c.name, cmd: 'look ' + c.id }, - { label: 'attack ' + c.name, cmd: 'attack ' + c.id }, - ], + 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: [ - { label: 'look ' + c.name, cmd: 'look ' + c.id }, - { label: 'attack ' + c.name, cmd: 'attack ' + c.id }, - ], + menuItems: menuItems, }); })); 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 1efb61e63..be4ce7e93 100644 --- a/internal/events/eventtypes.go +++ b/internal/events/eventtypes.go @@ -392,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 d219f93e6..309ecc6cf 100644 --- a/internal/mobcommands/suicide.go +++ b/internal/mobcommands/suicide.go @@ -123,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/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)