' +
- '
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 =
- '' +
- (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 =
+ '';
+
+ 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)