From 90957109924c3bfaab186deb77e4f9adb7307abd Mon Sep 17 00:00:00 2001 From: Volte6 <143822+Volte6@users.noreply.github.com> Date: Thu, 28 May 2026 16:46:29 -0700 Subject: [PATCH] centralized a lot of slot data to make it more flexible to changes --- _datafiles/html/admin/items-api.html | 20 ++ _datafiles/html/admin/mobs.html | 105 +++---- _datafiles/html/admin/races.html | 32 +- _datafiles/html/admin/users.html | 105 +++---- .../public/static/js/windows/window-gear.js | 120 ++++--- _datafiles/world/default/users/1.yaml | 32 +- internal/characters/AGENTS.md | 40 ++- internal/characters/character.go | 295 +++--------------- internal/characters/worn.go | 209 ++++++++----- internal/characters/worn_test.go | 81 +++++ internal/combat/armor_rank.go | 25 +- internal/combat/simulate.go | 13 +- internal/items/itemspec.go | 39 +++ internal/mobs/mobs.go | 13 +- internal/races/races.go | 20 +- internal/usercommands/inventory.panels.go | 29 +- internal/usercommands/status.panels.go | 29 +- internal/web/api_routes.go | 1 + internal/web/api_v1_items.go | 16 + modules/gmcp/AGENTS.md | 1 + modules/gmcp/gmcp.Char.go | 39 +-- 21 files changed, 606 insertions(+), 658 deletions(-) diff --git a/_datafiles/html/admin/items-api.html b/_datafiles/html/admin/items-api.html index 0d5edab6e..68d372e8b 100644 --- a/_datafiles/html/admin/items-api.html +++ b/_datafiles/html/admin/items-api.html @@ -45,6 +45,26 @@

Items API Reference

+
+ + GET + /admin/api/v1/items/equip-slots + Return ordered equipment slot names + items.read + +
+

Returns the ordered list of equipment slot names as strings, sourced from the server's canonical slot definition. Use this to build slot UIs dynamically instead of hard-coding slot names.

+
curl -s -u admin:password \ + http://{{.CONFIG.FilePaths.WebDomain}}/admin/api/v1/items/equip-slots
+
+
+ 200 Success +
{"success":true,"data":["weapon","offhand","head","neck","body","belt","gloves","ring","legs","feet"]}
+
+
+
+
+
GET diff --git a/_datafiles/html/admin/mobs.html b/_datafiles/html/admin/mobs.html index 236d74908..3430a3147 100644 --- a/_datafiles/html/admin/mobs.html +++ b/_datafiles/html/admin/mobs.html @@ -421,46 +421,7 @@

Mob Editor

Equipment
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
+
Inventory
@@ -530,11 +491,12 @@

Mob Editor

let scriptSync = null; async function init() { - const [mobsRes, racesRes, buffsRes, itemsRes] = await AdminAPI.all([ + const [mobsRes, racesRes, buffsRes, itemsRes, slotsRes] = await AdminAPI.all([ AdminAPI.get('/admin/api/v1/mobs'), AdminAPI.get('/admin/api/v1/races'), AdminAPI.get('/admin/api/v1/buffs'), AdminAPI.get('/admin/api/v1/items'), + AdminAPI.get('/admin/api/v1/items/equip-slots'), ]); if (!mobsRes.ok) { showStatus('Failed to load mobs: ' + mobsRes.error, false); return; } @@ -552,6 +514,10 @@

Mob Editor

allItems = (itemsRes.data.data || []).sort((a, b) => a.ItemId - b.ItemId); populateEquipSelects(allItems); } + if (slotsRes.ok) { + EQ_SLOTS = slotsRes.data.data || []; + } + buildEquipSlotsGrid(); populateZoneFilter(); renderList(allMobs); @@ -576,7 +542,28 @@

Mob Editor

// Equipment slots now use the Picker - no pre-population needed. } - const EQ_SLOTS = ['weapon','offhand','head','neck','body','belt','gloves','ring','legs','feet']; + let EQ_SLOTS = []; + + function buildEquipSlotsGrid() { + const grid = document.getElementById('equip-slots-grid'); + if (!grid) return; + grid.innerHTML = ''; + grid.style.display = 'contents'; + for (const slot of EQ_SLOTS) { + const label = slot.charAt(0).toUpperCase() + slot.slice(1); + const id = 'f-eq-' + slot; + const field = document.createElement('div'); + field.className = 'field'; + field.innerHTML = + '' + + '
' + + 'nothing' + + '' + + '' + + '
'; + grid.appendChild(field); + } + } function applyRaceEquipDisabled(raceId) { const race = allRaces[String(raceId)]; @@ -794,16 +781,10 @@

Mob Editor

// Equipment const eq = ch.Equipment || {}; - setEquipSlot('f-eq-weapon', eqItemId(eq.Weapon)); - setEquipSlot('f-eq-offhand', eqItemId(eq.Offhand)); - setEquipSlot('f-eq-head', eqItemId(eq.Head)); - setEquipSlot('f-eq-neck', eqItemId(eq.Neck)); - setEquipSlot('f-eq-body', eqItemId(eq.Body)); - setEquipSlot('f-eq-belt', eqItemId(eq.Belt)); - setEquipSlot('f-eq-gloves', eqItemId(eq.Gloves)); - setEquipSlot('f-eq-ring', eqItemId(eq.Ring)); - setEquipSlot('f-eq-legs', eqItemId(eq.Legs)); - setEquipSlot('f-eq-feet', eqItemId(eq.Feet)); + for (const slot of EQ_SLOTS) { + const key = slot.charAt(0).toUpperCase() + slot.slice(1); + setEquipSlot('f-eq-' + slot, eqItemId(eq[key])); + } // Inventory items renderItemRows(ch.Items || []); @@ -1341,18 +1322,14 @@

Mob Editor

Mysticism: { Training: parseInt(document.getElementById('f-stat-mysticism').value, 10) || 0 }, Perception: { Training: parseInt(document.getElementById('f-stat-perception').value, 10) || 0 }, }, - Equipment: { - Weapon: eqSlot('f-eq-weapon'), - Offhand: eqSlot('f-eq-offhand'), - Head: eqSlot('f-eq-head'), - Neck: eqSlot('f-eq-neck'), - Body: eqSlot('f-eq-body'), - Belt: eqSlot('f-eq-belt'), - Gloves: eqSlot('f-eq-gloves'), - Ring: eqSlot('f-eq-ring'), - Legs: eqSlot('f-eq-legs'), - Feet: eqSlot('f-eq-feet'), - }, + Equipment: (function () { + const eq = {}; + for (const slot of EQ_SLOTS) { + const key = slot.charAt(0).toUpperCase() + slot.slice(1); + eq[key] = eqSlot('f-eq-' + slot); + } + return eq; + }()), Items: collectItems(), Shop: collectShop(), SpellBook: collectSpellBook(), diff --git a/_datafiles/html/admin/races.html b/_datafiles/html/admin/races.html index 957d07c7a..61cb5014b 100644 --- a/_datafiles/html/admin/races.html +++ b/_datafiles/html/admin/races.html @@ -187,16 +187,6 @@

Races

Disabled Equipment Slots
Click a slot to toggle it. Dark slots are disabled for this race.
- - - - - - - - - -
@@ -215,9 +205,15 @@

Races

// ---- Load ---- async function loadRaces() { - const res = await AdminAPI.get('/admin/api/v1/races'); + const [res, slotsRes] = await AdminAPI.all([ + AdminAPI.get('/admin/api/v1/races'), + AdminAPI.get('/admin/api/v1/items/equip-slots'), + ]); if (!res.ok) { console.error('Failed to load races:', res.error); return; } racesData = (res.data && res.data.data) || {}; + if (slotsRes.ok) { + buildSlotsGrid(slotsRes.data.data || []); + } renderList(); DiceRollHelper.attach(document.getElementById('fDiceRoll')); const hashId = location.hash.slice(1); @@ -410,6 +406,20 @@

Races

if (type === 'success') setTimeout(() => { bar.className = 'status-bar'; }, 3000); } +function buildSlotsGrid(slots) { + const grid = document.getElementById('slotsGrid'); + if (!grid) return; + grid.innerHTML = ''; + for (const slot of slots) { + const label = slot.charAt(0).toUpperCase() + slot.slice(1); + const lbl = document.createElement('label'); + lbl.className = 'slot-item'; + lbl.setAttribute('onclick', 'toggleSlot(this)'); + lbl.innerHTML = ' ' + escHtml(label); + grid.appendChild(lbl); + } +} + function toggleSlot(label) { const cb = label.querySelector('.slot-cb'); cb.checked = !cb.checked; diff --git a/_datafiles/html/admin/users.html b/_datafiles/html/admin/users.html index 48cc19382..9e411abd1 100644 --- a/_datafiles/html/admin/users.html +++ b/_datafiles/html/admin/users.html @@ -449,46 +449,7 @@

User Editor

Equipment
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
-
- -
nothing
-
+
Inventory
@@ -576,12 +537,13 @@

User Editor

]; async function init() { - const [racesRes, buffsRes, itemsRes, questsRes, petsRes] = await AdminAPI.all([ + const [racesRes, buffsRes, itemsRes, questsRes, petsRes, slotsRes] = await AdminAPI.all([ AdminAPI.get('/admin/api/v1/races'), AdminAPI.get('/admin/api/v1/buffs'), AdminAPI.get('/admin/api/v1/items'), AdminAPI.get('/admin/api/v1/quests'), AdminAPI.get('/admin/api/v1/pets'), + AdminAPI.get('/admin/api/v1/items/equip-slots'), ]); if (racesRes.ok) { @@ -602,6 +564,10 @@

User Editor

if (petsRes.ok) { allPetTypes = petsRes.data.data || {}; } + if (slotsRes.ok) { + EQ_SLOTS = slotsRes.data.data || []; + } + buildEquipSlotsGrid(); buildSkillsGrid(); const hashId = parseInt(location.hash.slice(1), 10); if (hashId) loadUser(hashId); @@ -724,16 +690,10 @@

User Editor

// Equipment const eq = ch.Equipment || {}; - setEquipSlot('f-eq-weapon', eqItemId(eq.Weapon)); - setEquipSlot('f-eq-offhand', eqItemId(eq.Offhand)); - setEquipSlot('f-eq-head', eqItemId(eq.Head)); - setEquipSlot('f-eq-neck', eqItemId(eq.Neck)); - setEquipSlot('f-eq-body', eqItemId(eq.Body)); - setEquipSlot('f-eq-belt', eqItemId(eq.Belt)); - setEquipSlot('f-eq-gloves', eqItemId(eq.Gloves)); - setEquipSlot('f-eq-ring', eqItemId(eq.Ring)); - setEquipSlot('f-eq-legs', eqItemId(eq.Legs)); - setEquipSlot('f-eq-feet', eqItemId(eq.Feet)); + for (const slot of EQ_SLOTS) { + const key = slot.charAt(0).toUpperCase() + slot.slice(1); + setEquipSlot('f-eq-' + slot, eqItemId(eq[key])); + } // Inventory renderItemRows(ch.Items || []); @@ -770,7 +730,28 @@

User Editor

// Equipment slots // ------------------------------------------------------------------------- - const EQ_SLOTS = ['weapon','offhand','head','neck','body','belt','gloves','ring','legs','feet']; + let EQ_SLOTS = []; + + function buildEquipSlotsGrid() { + const grid = document.getElementById('equip-slots-grid'); + if (!grid) return; + grid.innerHTML = ''; + grid.style.display = 'contents'; + for (const slot of EQ_SLOTS) { + const label = slot.charAt(0).toUpperCase() + slot.slice(1); + const id = 'f-eq-' + slot; + const field = document.createElement('div'); + field.className = 'field'; + field.innerHTML = + '' + + '
' + + 'nothing' + + '' + + '' + + '
'; + grid.appendChild(field); + } + } function applyRaceEquipDisabled(raceId) { const race = allRaces[String(raceId)]; @@ -1228,18 +1209,14 @@

User Editor

Perception: { Training: parseInt(document.getElementById('f-stat-perception').value, 10) || 0 }, }, Skills: collectSkills(), - Equipment: { - Weapon: eqSlot('f-eq-weapon'), - Offhand: eqSlot('f-eq-offhand'), - Head: eqSlot('f-eq-head'), - Neck: eqSlot('f-eq-neck'), - Body: eqSlot('f-eq-body'), - Belt: eqSlot('f-eq-belt'), - Gloves: eqSlot('f-eq-gloves'), - Ring: eqSlot('f-eq-ring'), - Legs: eqSlot('f-eq-legs'), - Feet: eqSlot('f-eq-feet'), - }, + Equipment: (function () { + const eq = {}; + for (const slot of EQ_SLOTS) { + const key = slot.charAt(0).toUpperCase() + slot.slice(1); + eq[key] = eqSlot('f-eq-' + slot); + } + return eq; + }()), Items: collectItems(), Shop: collectShop(), SpellBook: collectSpellBook(), diff --git a/_datafiles/html/public/static/js/windows/window-gear.js b/_datafiles/html/public/static/js/windows/window-gear.js index ac1a79050..295ff6b5b 100644 --- a/_datafiles/html/public/static/js/windows/window-gear.js +++ b/_datafiles/html/public/static/js/windows/window-gear.js @@ -313,22 +313,6 @@ } `); - // ----------------------------------------------------------------------- - // Data - // ----------------------------------------------------------------------- - const EQUIP_SLOTS = [ - { key: 'head', label: 'Head' }, - { key: 'neck', label: 'Neck' }, - { key: 'body', label: 'Body' }, - { key: 'weapon', label: 'Weapon' }, - { key: 'offhand', label: 'Offhand' }, - { key: 'gloves', label: 'Gloves' }, - { key: 'belt', label: 'Belt' }, - { key: 'ring', label: 'Ring' }, - { key: 'legs', label: 'Legs' }, - { key: 'feet', label: 'Feet' }, - ]; - // ----------------------------------------------------------------------- // Tooltip // ----------------------------------------------------------------------- @@ -499,16 +483,6 @@ // ----------------------------------------------------------------------- // DOM factory // ----------------------------------------------------------------------- - function buildEquipRows() { - return EQUIP_SLOTS.map(s => - '
' + - '' + s.label + '' + - 'empty' + - '' + - '
' - ).join(''); - } - function createDOM() { const el = document.createElement('div'); el.id = 'gear-window'; @@ -517,32 +491,17 @@ '' + '' + '
' + - - '
' + - buildEquipRows() + - '
' + - + '
' + '
' + '
' + 'Carried Items' + - '0 / \u2014' + + '0 / ' + '
' + '
Empty
' + '
'; document.body.appendChild(el); makeTabSwitcher(el); - - EQUIP_SLOTS.forEach(s => { - const rowEl = el.querySelector('#gw-eqrow-' + s.key); - if (!rowEl) { return; } - attachTooltip(rowEl); - rowEl.addEventListener('click', function(e) { - const menuItems = _equipMenuItems(rowItemData.get(rowEl)); - if (menuItems) { uiMenu(e, menuItems); } - }); - }); - return el; } @@ -573,16 +532,79 @@ // ----------------------------------------------------------------------- // Update functions // ----------------------------------------------------------------------- + function _makeEquipRow(key) { + const label = key.charAt(0).toUpperCase() + key.slice(1); + const rowEl = document.createElement('div'); + rowEl.className = 'gw-equip-row'; + rowEl.id = 'gw-eqrow-' + key; + + const slotEl = document.createElement('span'); + slotEl.className = 'gw-equip-slot'; + slotEl.textContent = label; + + const nameEl = document.createElement('span'); + nameEl.className = 'gw-equip-name empty'; + nameEl.id = 'gw-eq-' + key; + nameEl.textContent = 'empty'; + + const badgeEl = document.createElement('span'); + badgeEl.className = 'gw-equip-badge'; + badgeEl.id = 'gw-eqb-' + key; + badgeEl.style.display = 'none'; + + rowEl.appendChild(slotEl); + rowEl.appendChild(nameEl); + rowEl.appendChild(badgeEl); + + attachTooltip(rowEl); + rowEl.addEventListener('click', function(e) { + const menuItems = _equipMenuItems(rowItemData.get(rowEl)); + if (menuItems) { uiMenu(e, menuItems); } + }); + + return rowEl; + } + function updateWorn() { const inv = Client.GMCPStructs.Char && Client.GMCPStructs.Char.Inventory; if (!inv || !inv.Worn) { return; } - const worn = inv.Worn; - EQUIP_SLOTS.forEach(slot => { - const item = worn[slot.key]; - const rowEl = document.getElementById('gw-eqrow-' + slot.key); - const nameEl = document.getElementById('gw-eq-' + slot.key); - const badgeEl = document.getElementById('gw-eqb-' + slot.key); + const worn = inv.Worn; + const wornEl = document.getElementById('gw-worn'); + if (!wornEl) { return; } + + // Build or reorder rows to match the current payload keys. + const keys = Object.keys(worn); + + // Remove rows for slots no longer present in the payload. + const existing = wornEl.querySelectorAll('.gw-equip-row'); + existing.forEach(function(row) { + const key = row.id.replace('gw-eqrow-', ''); + if (!worn.hasOwnProperty(key)) { + rowItemData.delete(row); + row.remove(); + } + }); + + // Insert/reorder rows to match sorted key order. + keys.forEach(function(key, idx) { + let rowEl = document.getElementById('gw-eqrow-' + key); + if (!rowEl) { + rowEl = _makeEquipRow(key); + } + // Move into correct position if needed. + const current = wornEl.children[idx]; + if (current !== rowEl) { + wornEl.insertBefore(rowEl, current || null); + } + }); + + // Update content of every row. + keys.forEach(function(key) { + const item = worn[key]; + const rowEl = document.getElementById('gw-eqrow-' + key); + const nameEl = document.getElementById('gw-eq-' + key); + const badgeEl = document.getElementById('gw-eqb-' + key); if (!rowEl || !nameEl || !badgeEl) { return; } if (!item || !item.name || item.name === '-nothing-') { diff --git a/_datafiles/world/default/users/1.yaml b/_datafiles/world/default/users/1.yaml index 3943826d5..cb69850ea 100644 --- a/_datafiles/world/default/users/1.yaml +++ b/_datafiles/world/default/users/1.yaml @@ -1,7 +1,7 @@ userid: 1 role: admin username: admin -password: password +password: $2a$10$GDAPK3BlI8U5xGUeYI.fC.vVwv0gGH5Mh//wPguSwfTPWKy58.XU. joined: 2024-10-31T13:28:45.395873-07:00 macros: =1: e;e;e;e;e;e;e;e @@ -41,10 +41,10 @@ character: experience: 37692 trainingpoints: 46 statpoints: 8 - health: 30 + health: 39 mana: 44 actionpoints: 203 - alignment: -1 + alignment: 0 gold: 9950 bank: 0 spellbook: @@ -59,27 +59,34 @@ character: uses: 1 - itemid: 10012 - itemid: 10001 - - itemid: 10006 - uncursed: true - itemid: 30002 uses: 1 - itemid: 30004 uses: 3 + - itemid: 10004 + - itemid: 20004 buffs: list: - buffid: 29 permabuff: true - roundcounter: 4 + roundcounter: 32 triggersleft: 1000000000 + triggersinitial: 1000000000 - buffid: 28 permabuff: true - roundcounter: 5 + roundcounter: 3 triggersleft: 1000000000 + triggersinitial: 1000000000 + - buffid: 1 + permabuff: true + roundcounter: 25 + triggersleft: 1000000000 + triggersinitial: 1000000000 equipment: weapon: - itemid: 10004 - offhand: - itemid: 20004 + itemid: 10006 + uncursed: true + offhand: {} head: itemid: 20043 neck: {} @@ -133,10 +140,11 @@ character: 55: 40 pet: name: fisher - namestyle: :mute-dblue type: mule - food: 3 + roundactchance: 10 + food: 2 level: 10 + lastlevelcheck: "6.73" abilities: - levelgranted: 1 capacity: 1 diff --git a/internal/characters/AGENTS.md b/internal/characters/AGENTS.md index eb2dd61db..7b712ed97 100644 --- a/internal/characters/AGENTS.md +++ b/internal/characters/AGENTS.md @@ -29,6 +29,8 @@ The `internal/characters` package is the core character system for GoMud, handli - **Equipment slots**: Weapon, Offhand, Head, Neck, Body, Belt, Gloves, Ring, Legs, Feet - **Stat modifications**: Equipment provides stat bonuses aggregated across all slots - **Item management**: Worn item tracking and validation +- **Slot accessors**: `Get(slot)` and `Set(slot, item)` are the only places that map an `items.ItemType` to a `Worn` struct field; all other code iterates via `AllSlots()`, `ArmorSlots()`, or `WeaponSlots()` +- **Display labels**: `SlotLabel(slot)` returns the canonical UI label (e.g. `"Head:"`) so rendering code does not hard-code slot names ### Character States and Modifiers - **Alignment system** (`alignment.go`): Good/neutral/evil alignment with numeric values (-100 to +100) @@ -115,4 +117,40 @@ Comprehensive test coverage in `*_test.go` files covering: - `RoomBitset` YAML round-trip serialization - `MarkVisitedRoom`, `HasVisitedRoom`, and `ZoneVisitProgress` integration -This package serves as the foundation for all character-related functionality in GoMud, providing a rich and flexible character model that supports both player and NPC needs. \ No newline at end of file +This package serves as the foundation for all character-related functionality in GoMud, providing a rich and flexible character model that supports both player and NPC needs. + +## How can new slots be added, removed, or changed? + +The slot system is designed so that adding, removing, or renaming a slot requires touching the fewest possible files. Follow these steps in order. + +### Adding a new slot + +1. **`internal/items/itemspec.go`** — Add the new `ItemType` constant (e.g. `Shoulders ItemType = "shoulders"`). Add it to `AllEquipSlots()`, and to `ArmorSlots()` or `WeaponSlots()` as appropriate. This is the single source of truth for the ordered slot list. + +2. **`internal/characters/worn.go`** — Add the new field to the `Worn` struct with a matching YAML tag. Add a case for it in `Get()` and `Set()`. Add it to `SlotLabel()`. No other functions in this file need changing — `StatMod`, `EnableAll`, `GetAllItems`, and the package-level helpers all loop via `AllSlots()` and will pick up the new slot automatically. + +3. **`internal/characters/character.go`** — The `Wear()` function contains an explicit `switch` for slot-specific equip logic. If the new slot has standard equip behavior (check disabled, displace old item, place new item), add a `case` for it alongside the existing simple armor cases (`Head` through `Feet`). If it has special behavior like `Weapon`/`Offhand`, add full case logic. All other functions in this file loop via `AllSlots()` and need no changes. + +4. **Race data files** — If any races should have the new slot disabled, add the slot name to their `disabledslots` list in the YAML data files under `_datafiles/races/`. + +5. **Item data files** — Create item specs with `type: newslot` under the appropriate folder in `_datafiles/items/` so items can be assigned to the new slot. + +### Removing a slot + +Reverse the steps above. Remove the constant from `AllEquipSlots()` (and `ArmorSlots()`/`WeaponSlots()`), remove the struct field and its `Get`/`Set`/`SlotLabel` cases, remove the `Wear()` case, and migrate or delete any item data files that used the slot type. + +### Renaming a slot + +Rename the `ItemType` constant value string and update the YAML tag on the `Worn` field to match. The constant name itself (e.g. `items.Shoulders`) can be renamed with a project-wide symbol rename. The string value (e.g. `"shoulders"`) is persisted in character save files and item data files, so a data migration is required if live saves exist. + +### Files that do NOT need changes for a new slot + +Because they loop via `AllSlots()` or delegate to `Worn.Get()`/`Set()`: +- `Worn.StatMod`, `Worn.EnableAll`, `Worn.GetAllItems` +- `Character.GetDefense`, `GetAllWornItems`, `GetGearValue`, `Validate`, `Uncurse`, `RemoveFromBody`, `BestUpgrades`, `SetRace`, `FindOnBody` +- `internal/races/races.go` `GetEnabledSlots` +- `internal/combat/armor_rank.go` `armorSlotSet` +- `internal/usercommands/inventory.panels.go` equipment panel +- `internal/usercommands/status.panels.go` stat bonuses panel +- `internal/mobs/mobs.go` and `internal/combat/simulate.go` equipment validation loops +- `modules/gmcp/gmcp.Char.go` — `buildWornPayload` iterates `items.AllEquipSlots()` automatically \ No newline at end of file diff --git a/internal/characters/character.go b/internal/characters/character.go index 074c21cbf..96fa9e369 100644 --- a/internal/characters/character.go +++ b/internal/characters/character.go @@ -567,16 +567,10 @@ func (c *Character) GetAllSkillRanks() map[string]int { // Returns an integer representing a % damage reduction func (c *Character) GetDefense() int { - reduction := c.Equipment.Weapon.GetDefense() + - c.Equipment.Offhand.GetDefense() + - c.Equipment.Head.GetDefense() + - c.Equipment.Neck.GetDefense() + - c.Equipment.Body.GetDefense() + - c.Equipment.Belt.GetDefense() + - c.Equipment.Gloves.GetDefense() + - c.Equipment.Ring.GetDefense() + - c.Equipment.Legs.GetDefense() + - c.Equipment.Feet.GetDefense() + reduction := 0 + for _, slot := range AllSlots() { + reduction += c.Equipment.Get(slot).GetDefense() + } //reduction = int(float64(reduction) / 9) @@ -870,17 +864,7 @@ func (c *Character) FindOnBody(itemName string) (items.Item, bool) { return items.Item{}, false } - partialMatch, fullMatch := items.FindMatchIn(itemName, - c.Equipment.Weapon, - c.Equipment.Offhand, - c.Equipment.Head, - c.Equipment.Neck, - c.Equipment.Body, - c.Equipment.Belt, - c.Equipment.Gloves, - c.Equipment.Ring, - c.Equipment.Legs, - c.Equipment.Feet) + partialMatch, fullMatch := items.FindMatchIn(itemName, c.Equipment.GetAllItems()...) if fullMatch.ItemId != 0 { return fullMatch, true @@ -1658,16 +1642,9 @@ func (c *Character) Validate(recalcPermaBuffs ...bool) error { for i := range c.Items { c.Items[i].Validate() } - c.Equipment.Weapon.Validate() - c.Equipment.Offhand.Validate() - c.Equipment.Head.Validate() - c.Equipment.Neck.Validate() - c.Equipment.Body.Validate() - c.Equipment.Belt.Validate() - c.Equipment.Gloves.Validate() - c.Equipment.Ring.Validate() - c.Equipment.Legs.Validate() - c.Equipment.Feet.Validate() + for _, slot := range AllSlots() { + c.Equipment.Get(slot).Validate() + } // Done with validation if raceInfo := races.GetRace(c.GetRaceId()); raceInfo != nil { @@ -1679,60 +1656,17 @@ func (c *Character) Validate(recalcPermaBuffs ...bool) error { for _, disabledSlot := range raceInfo.DisabledSlots { - var itemFoundInDisabledSlot items.Item = items.ItemDisabledSlot + slotType := items.ItemType(disabledSlot) + slotItem := c.Equipment.Get(slotType) + if slotItem == nil { + continue + } - switch items.ItemType(disabledSlot) { - case items.Weapon: - if c.Equipment.Weapon.ItemId > 0 { // Did we find somethign in a disabled slot? - itemFoundInDisabledSlot = c.Equipment.Weapon - } - c.Equipment.Weapon = items.ItemDisabledSlot - case items.Offhand: - if c.Equipment.Offhand.ItemId > 0 { // Did we find somethign in a disabled slot? - itemFoundInDisabledSlot = c.Equipment.Offhand - } - c.Equipment.Offhand = items.ItemDisabledSlot - case items.Head: - if c.Equipment.Head.ItemId > 0 { // Did we find somethign in a disabled slot? - itemFoundInDisabledSlot = c.Equipment.Head - } - c.Equipment.Head = items.ItemDisabledSlot - case items.Neck: - if c.Equipment.Neck.ItemId > 0 { // Did we find somethign in a disabled slot? - itemFoundInDisabledSlot = c.Equipment.Neck - } - c.Equipment.Neck = items.ItemDisabledSlot - case items.Body: - if c.Equipment.Body.ItemId > 0 { // Did we find somethign in a disabled slot? - itemFoundInDisabledSlot = c.Equipment.Body - } - c.Equipment.Body = items.ItemDisabledSlot - case items.Belt: - if c.Equipment.Belt.ItemId > 0 { // Did we find somethign in a disabled slot? - itemFoundInDisabledSlot = c.Equipment.Belt - } - c.Equipment.Belt = items.ItemDisabledSlot - case items.Gloves: - if c.Equipment.Gloves.ItemId > 0 { // Did we find somethign in a disabled slot? - itemFoundInDisabledSlot = c.Equipment.Gloves - } - c.Equipment.Gloves = items.ItemDisabledSlot - case items.Ring: - if c.Equipment.Ring.ItemId > 0 { // Did we find somethign in a disabled slot? - itemFoundInDisabledSlot = c.Equipment.Ring - } - c.Equipment.Ring = items.ItemDisabledSlot - case items.Legs: - if c.Equipment.Legs.ItemId > 0 { // Did we find somethign in a disabled slot? - itemFoundInDisabledSlot = c.Equipment.Legs - } - c.Equipment.Legs = items.ItemDisabledSlot - case items.Feet: - if c.Equipment.Feet.ItemId > 0 { // Did we find somethign in a disabled slot? - itemFoundInDisabledSlot = c.Equipment.Feet - } - c.Equipment.Feet = items.ItemDisabledSlot + var itemFoundInDisabledSlot items.Item = items.ItemDisabledSlot + if slotItem.ItemId > 0 { + itemFoundInDisabledSlot = *slotItem } + c.Equipment.Set(slotType, items.ItemDisabledSlot) if !itemFoundInDisabledSlot.IsDisabled() { c.StoreItem(itemFoundInDisabledSlot) @@ -1917,47 +1851,8 @@ func (c *Character) BestUpgrades() map[items.ItemType]items.Item { continue } // Skip disabled slots (ItemId == -1 means the race cannot use this slot). - switch slotType { - case items.Weapon: - if c.Equipment.Weapon.IsDisabled() { - continue - } - case items.Offhand: - if c.Equipment.Offhand.IsDisabled() { - continue - } - case items.Head: - if c.Equipment.Head.IsDisabled() { - continue - } - case items.Neck: - if c.Equipment.Neck.IsDisabled() { - continue - } - case items.Body: - if c.Equipment.Body.IsDisabled() { - continue - } - case items.Belt: - if c.Equipment.Belt.IsDisabled() { - continue - } - case items.Gloves: - if c.Equipment.Gloves.IsDisabled() { - continue - } - case items.Ring: - if c.Equipment.Ring.IsDisabled() { - continue - } - case items.Legs: - if c.Equipment.Legs.IsDisabled() { - continue - } - case items.Feet: - if c.Equipment.Feet.IsDisabled() { - continue - } + if slotItem := c.Equipment.Get(slotType); slotItem != nil && slotItem.IsDisabled() { + continue } upgrades[slotType] = candidate } @@ -2002,70 +1897,20 @@ func (c *Character) BestUpgrades() map[items.ItemType]items.Item { func (c *Character) GetAllWornItems() []items.Item { wornItems := []items.Item{} - if c.Equipment.Weapon.ItemId > 0 { - wornItems = append(wornItems, c.Equipment.Weapon) - } - if c.Equipment.Offhand.ItemId > 0 { - wornItems = append(wornItems, c.Equipment.Offhand) - } - if c.Equipment.Head.ItemId > 0 { - wornItems = append(wornItems, c.Equipment.Head) - } - if c.Equipment.Neck.ItemId > 0 { - wornItems = append(wornItems, c.Equipment.Neck) - } - if c.Equipment.Body.ItemId > 0 { - wornItems = append(wornItems, c.Equipment.Body) - } - if c.Equipment.Belt.ItemId > 0 { - wornItems = append(wornItems, c.Equipment.Belt) - } - if c.Equipment.Gloves.ItemId > 0 { - wornItems = append(wornItems, c.Equipment.Gloves) - } - if c.Equipment.Ring.ItemId > 0 { - wornItems = append(wornItems, c.Equipment.Ring) - } - if c.Equipment.Legs.ItemId > 0 { - wornItems = append(wornItems, c.Equipment.Legs) - } - if c.Equipment.Feet.ItemId > 0 { - wornItems = append(wornItems, c.Equipment.Feet) + for _, slot := range AllSlots() { + if itm := c.Equipment.Get(slot); itm.ItemId > 0 { + wornItems = append(wornItems, *itm) + } } return wornItems } func (c *Character) GetGearValue() int { value := 0 - if c.Equipment.Weapon.ItemId > 0 { - value += c.Equipment.Weapon.GetSpec().Value - } - if c.Equipment.Offhand.ItemId > 0 { - value += c.Equipment.Offhand.GetSpec().Value - } - if c.Equipment.Head.ItemId > 0 { - value += c.Equipment.Head.GetSpec().Value - } - if c.Equipment.Neck.ItemId > 0 { - value += c.Equipment.Neck.GetSpec().Value - } - if c.Equipment.Body.ItemId > 0 { - value += c.Equipment.Body.GetSpec().Value - } - if c.Equipment.Belt.ItemId > 0 { - value += c.Equipment.Belt.GetSpec().Value - } - if c.Equipment.Gloves.ItemId > 0 { - value += c.Equipment.Gloves.GetSpec().Value - } - if c.Equipment.Ring.ItemId > 0 { - value += c.Equipment.Ring.GetSpec().Value - } - if c.Equipment.Legs.ItemId > 0 { - value += c.Equipment.Legs.GetSpec().Value - } - if c.Equipment.Feet.ItemId > 0 { - value += c.Equipment.Feet.GetSpec().Value + for _, slot := range AllSlots() { + if itm := c.Equipment.Get(slot); itm.ItemId > 0 { + value += itm.GetSpec().Value + } } return value } @@ -2216,33 +2061,15 @@ func (c *Character) Wear(i items.Item) (returnItems []items.Item, newItemWorn bo func (c *Character) RemoveFromBody(i items.Item) bool { - if i.Equals(c.Equipment.Weapon) { - c.Equipment.Weapon = items.Item{} - } else if i.Equals(c.Equipment.Offhand) { - c.Equipment.Offhand = items.Item{} - } else if i.Equals(c.Equipment.Head) { - c.Equipment.Head = items.Item{} - } else if i.Equals(c.Equipment.Neck) { - c.Equipment.Neck = items.Item{} - } else if i.Equals(c.Equipment.Body) { - c.Equipment.Body = items.Item{} - } else if i.Equals(c.Equipment.Belt) { - c.Equipment.Belt = items.Item{} - } else if i.Equals(c.Equipment.Gloves) { - c.Equipment.Gloves = items.Item{} - } else if i.Equals(c.Equipment.Ring) { - c.Equipment.Ring = items.Item{} - } else if i.Equals(c.Equipment.Legs) { - c.Equipment.Legs = items.Item{} - } else if i.Equals(c.Equipment.Feet) { - c.Equipment.Feet = items.Item{} - } else { - return false + for _, slot := range AllSlots() { + if i.Equals(*c.Equipment.Get(slot)) { + c.Equipment.Set(slot, items.Item{}) + c.reapplyPermabuffs(i) + return true + } } - c.reapplyPermabuffs(i) - - return true + return false } // Used with SpawnInfo to gift spawning mobs with permabuffs @@ -2314,54 +2141,12 @@ func (c *Character) Uncurse() []items.Item { uncursedList := []items.Item{} - if c.Equipment.Weapon.IsCursed() { - c.Equipment.Weapon.Uncursed = true - uncursedList = append(uncursedList, c.Equipment.Weapon) - } - - if c.Equipment.Offhand.IsCursed() { - c.Equipment.Offhand.Uncursed = true - uncursedList = append(uncursedList, c.Equipment.Offhand) - } - - if c.Equipment.Head.IsCursed() { - c.Equipment.Head.Uncursed = true - uncursedList = append(uncursedList, c.Equipment.Head) - } - - if c.Equipment.Neck.IsCursed() { - c.Equipment.Neck.Uncursed = true - uncursedList = append(uncursedList, c.Equipment.Neck) - } - - if c.Equipment.Body.IsCursed() { - c.Equipment.Body.Uncursed = true - uncursedList = append(uncursedList, c.Equipment.Body) - } - - if c.Equipment.Belt.IsCursed() { - c.Equipment.Belt.Uncursed = true - uncursedList = append(uncursedList, c.Equipment.Belt) - } - - if c.Equipment.Gloves.IsCursed() { - c.Equipment.Gloves.Uncursed = true - uncursedList = append(uncursedList, c.Equipment.Gloves) - } - - if c.Equipment.Ring.IsCursed() { - c.Equipment.Ring.Uncursed = true - uncursedList = append(uncursedList, c.Equipment.Ring) - } - - if c.Equipment.Legs.IsCursed() { - c.Equipment.Legs.Uncursed = true - uncursedList = append(uncursedList, c.Equipment.Legs) - } - - if c.Equipment.Feet.IsCursed() { - c.Equipment.Feet.Uncursed = true - uncursedList = append(uncursedList, c.Equipment.Feet) + for _, slot := range AllSlots() { + itm := c.Equipment.Get(slot) + if itm.IsCursed() { + itm.Uncursed = true + uncursedList = append(uncursedList, *itm) + } } return uncursedList diff --git a/internal/characters/worn.go b/internal/characters/worn.go index 745a20482..52f961057 100644 --- a/internal/characters/worn.go +++ b/internal/characters/worn.go @@ -15,99 +15,140 @@ type Worn struct { Feet items.Item `yaml:"feet,omitempty"` } -func (w *Worn) StatMod(stat ...string) int { - - return w.Weapon.StatMod(stat...) + - w.Offhand.StatMod(stat...) + - w.Head.StatMod(stat...) + - w.Neck.StatMod(stat...) + - w.Body.StatMod(stat...) + - w.Belt.StatMod(stat...) + - w.Gloves.StatMod(stat...) + - w.Ring.StatMod(stat...) + - w.Legs.StatMod(stat...) + - w.Feet.StatMod(stat...) +// Get returns a pointer to the item in the given slot, or nil for an +// unrecognized slot type. This is the single place that maps an ItemType to a +// Worn struct field; add new slots here and nowhere else. +func (w *Worn) Get(slot items.ItemType) *items.Item { + switch slot { + case items.Weapon: + return &w.Weapon + case items.Offhand: + return &w.Offhand + case items.Head: + return &w.Head + case items.Neck: + return &w.Neck + case items.Body: + return &w.Body + case items.Belt: + return &w.Belt + case items.Gloves: + return &w.Gloves + case items.Ring: + return &w.Ring + case items.Legs: + return &w.Legs + case items.Feet: + return &w.Feet + } + return nil } -func (w *Worn) EnableAll() { - if w.Weapon.ItemId < 0 { - w.Weapon = items.Item{} - } - if w.Offhand.ItemId < 0 { - w.Offhand = items.Item{} - } - if w.Head.ItemId < 0 { - w.Head = items.Item{} - } - if w.Neck.ItemId < 0 { - w.Neck = items.Item{} - } - if w.Body.ItemId < 0 { - w.Body = items.Item{} - } - if w.Belt.ItemId < 0 { - w.Belt = items.Item{} - } - if w.Gloves.ItemId < 0 { - w.Gloves = items.Item{} - } - if w.Ring.ItemId < 0 { - w.Ring = items.Item{} - } - if w.Legs.ItemId < 0 { - w.Legs = items.Item{} - } - if w.Feet.ItemId < 0 { - w.Feet = items.Item{} +// Set places item into the given slot. Does nothing for an unrecognized slot. +func (w *Worn) Set(slot items.ItemType, item items.Item) { + switch slot { + case items.Weapon: + w.Weapon = item + case items.Offhand: + w.Offhand = item + case items.Head: + w.Head = item + case items.Neck: + w.Neck = item + case items.Body: + w.Body = item + case items.Belt: + w.Belt = item + case items.Gloves: + w.Gloves = item + case items.Ring: + w.Ring = item + case items.Legs: + w.Legs = item + case items.Feet: + w.Feet = item } } -func (w *Worn) GetAllItems() []items.Item { - iList := []items.Item{} - if w.Weapon.ItemId > 0 { - iList = append(iList, w.Weapon) - } - if w.Offhand.ItemId > 0 { - iList = append(iList, w.Offhand) - } - if w.Head.ItemId > 0 { - iList = append(iList, w.Head) - } - if w.Neck.ItemId > 0 { - iList = append(iList, w.Neck) - } - if w.Body.ItemId > 0 { - iList = append(iList, w.Body) - } - if w.Belt.ItemId > 0 { - iList = append(iList, w.Belt) - } - if w.Gloves.ItemId > 0 { - iList = append(iList, w.Gloves) - } - if w.Ring.ItemId > 0 { - iList = append(iList, w.Ring) +// AllSlots returns every equipment slot in canonical display order. +// Delegates to items.AllEquipSlots() so the single source of truth lives +// alongside the ItemType constants. +func AllSlots() []items.ItemType { + return items.AllEquipSlots() +} + +// WeaponSlots returns the slots that hold weapons. +func WeaponSlots() []items.ItemType { + return items.WeaponSlots() +} + +// ArmorSlots returns every equipment slot except Weapon. +func ArmorSlots() []items.ItemType { + return items.ArmorSlots() +} + +// SlotLabel returns the short display label (with trailing colon) for a slot, +// e.g. items.Head -> "Head:". Used by UI code so label strings are not +// scattered across rendering packages. +func SlotLabel(slot items.ItemType) string { + switch slot { + case items.Weapon: + return "Weapon:" + case items.Offhand: + return "Offhand:" + case items.Head: + return "Head:" + case items.Neck: + return "Neck:" + case items.Body: + return "Body:" + case items.Belt: + return "Belt:" + case items.Gloves: + return "Gloves:" + case items.Ring: + return "Ring:" + case items.Legs: + return "Legs:" + case items.Feet: + return "Feet:" + } + return string(slot) + ":" +} + +// GetAllSlotTypes returns all slot names as strings. +// Kept for backward compatibility; prefer AllSlots() for typed access. +func GetAllSlotTypes() []string { + slots := AllSlots() + out := make([]string, len(slots)) + for i, s := range slots { + out[i] = string(s) } - if w.Legs.ItemId > 0 { - iList = append(iList, w.Legs) + return out +} + +func (w *Worn) StatMod(stat ...string) int { + total := 0 + for _, slot := range AllSlots() { + total += w.Get(slot).StatMod(stat...) } - if w.Feet.ItemId > 0 { - iList = append(iList, w.Feet) + return total +} + +func (w *Worn) EnableAll() { + for _, slot := range AllSlots() { + if w.Get(slot).ItemId < 0 { + w.Set(slot, items.Item{}) + } } - return iList } -func GetAllSlotTypes() []string { - return []string{ - string(items.Weapon), - string(items.Offhand), - string(items.Head), - string(items.Neck), - string(items.Body), - string(items.Belt), - string(items.Gloves), - string(items.Ring), - string(items.Legs), - string(items.Feet), +func (w *Worn) GetAllItems() []items.Item { + out := []items.Item{} + for _, slot := range AllSlots() { + if itm := w.Get(slot); itm.ItemId > 0 { + out = append(out, *itm) + } } + return out } diff --git a/internal/characters/worn_test.go b/internal/characters/worn_test.go index 24b3eff67..44ccccca6 100644 --- a/internal/characters/worn_test.go +++ b/internal/characters/worn_test.go @@ -254,3 +254,84 @@ func TestGetAllSlotTypes(t *testing.T) { got := GetAllSlotTypes() assert.Equal(t, expected, got, "GetAllSlotTypes should return all slot types in correct order") } + +func TestAllSlots(t *testing.T) { + expected := []items.ItemType{ + items.Weapon, items.Offhand, items.Head, items.Neck, items.Body, + items.Belt, items.Gloves, items.Ring, items.Legs, items.Feet, + } + assert.Equal(t, expected, AllSlots()) +} + +func TestWeaponSlots(t *testing.T) { + expected := []items.ItemType{items.Weapon, items.Offhand} + assert.Equal(t, expected, WeaponSlots()) +} + +func TestArmorSlots(t *testing.T) { + expected := []items.ItemType{ + items.Offhand, items.Head, items.Neck, items.Body, + items.Belt, items.Gloves, items.Ring, items.Legs, items.Feet, + } + assert.Equal(t, expected, ArmorSlots()) + for _, s := range ArmorSlots() { + assert.NotEqual(t, items.Weapon, s, "ArmorSlots must not contain items.Weapon") + } +} + +func TestSlotLabel(t *testing.T) { + tests := []struct { + slot items.ItemType + want string + }{ + {items.Weapon, "Weapon:"}, + {items.Offhand, "Offhand:"}, + {items.Head, "Head:"}, + {items.Neck, "Neck:"}, + {items.Body, "Body:"}, + {items.Belt, "Belt:"}, + {items.Gloves, "Gloves:"}, + {items.Ring, "Ring:"}, + {items.Legs, "Legs:"}, + {items.Feet, "Feet:"}, + {items.ItemType("unknown"), "unknown:"}, + } + for _, tt := range tests { + t.Run(string(tt.slot), func(t *testing.T) { + assert.Equal(t, tt.want, SlotLabel(tt.slot)) + }) + } +} + +func TestWorn_Get(t *testing.T) { + w := Worn{ + Weapon: items.Item{ItemId: 1}, + Offhand: items.Item{ItemId: 2}, + Head: items.Item{ItemId: 3}, + Neck: items.Item{ItemId: 4}, + Body: items.Item{ItemId: 5}, + Belt: items.Item{ItemId: 6}, + Gloves: items.Item{ItemId: 7}, + Ring: items.Item{ItemId: 8}, + Legs: items.Item{ItemId: 9}, + Feet: items.Item{ItemId: 10}, + } + for i, slot := range AllSlots() { + got := w.Get(slot) + assert.NotNil(t, got, "Get(%v) should not be nil", slot) + assert.Equal(t, i+1, got.ItemId, "Get(%v) ItemId", slot) + } + assert.Nil(t, w.Get(items.ItemType("nonexistent")), "Get with unknown slot should return nil") +} + +func TestWorn_Set(t *testing.T) { + w := Worn{} + for i, slot := range AllSlots() { + w.Set(slot, items.Item{ItemId: i + 1}) + } + for i, slot := range AllSlots() { + assert.Equal(t, i+1, w.Get(slot).ItemId, "Set(%v) did not store correctly", slot) + } + // Unknown slot must not panic. + w.Set(items.ItemType("nonexistent"), items.Item{ItemId: 99}) +} diff --git a/internal/combat/armor_rank.go b/internal/combat/armor_rank.go index 7d940037b..7e1f206be 100644 --- a/internal/combat/armor_rank.go +++ b/internal/combat/armor_rank.go @@ -93,19 +93,16 @@ func wornBuffValue(spec items.ItemSpec) int { return val } -// armorSlots lists every item type that occupies an equipment slot and -// provides passive protection or stat benefits. -var armorSlots = map[items.ItemType]bool{ - items.Offhand: true, - items.Head: true, - items.Neck: true, - items.Body: true, - items.Belt: true, - items.Gloves: true, - items.Ring: true, - items.Legs: true, - items.Feet: true, -} +// armorSlotSet is a set of every item type that occupies an equipment slot +// and provides passive protection or stat benefits. Built from items.ArmorSlots() +// so adding or removing slots only requires changing that one function. +var armorSlotSet = func() map[items.ItemType]bool { + m := make(map[items.ItemType]bool) + for _, s := range items.ArmorSlots() { + m[s] = true + } + return m +}() // statWeight returns the eHP-equivalent weight for one point of a given stat // mod name, derived from the combat engine's configured range constants. @@ -162,7 +159,7 @@ func RankArmor() (byDefense, byAdjDefense, byScore []ArmorRank) { ranks := make([]ArmorRank, 0, len(allSpecs)) for _, spec := range allSpecs { - if !armorSlots[spec.Type] { + if !armorSlotSet[spec.Type] { continue } diff --git a/internal/combat/simulate.go b/internal/combat/simulate.go index 3a6c68c50..1ccda08c1 100644 --- a/internal/combat/simulate.go +++ b/internal/combat/simulate.go @@ -72,16 +72,9 @@ func newSimMob(mobId mobs.MobId, forceLevel int) (*mobs.Mob, error) { } } - mob.Character.Equipment.Weapon.Validate() - mob.Character.Equipment.Offhand.Validate() - mob.Character.Equipment.Head.Validate() - mob.Character.Equipment.Neck.Validate() - mob.Character.Equipment.Body.Validate() - mob.Character.Equipment.Belt.Validate() - mob.Character.Equipment.Gloves.Validate() - mob.Character.Equipment.Ring.Validate() - mob.Character.Equipment.Legs.Validate() - mob.Character.Equipment.Feet.Validate() + for _, slot := range characters.AllSlots() { + mob.Character.Equipment.Get(slot).Validate() + } mob.Validate() mob.Character.Validate(true) diff --git a/internal/items/itemspec.go b/internal/items/itemspec.go index 6cd5e2703..acce61068 100644 --- a/internal/items/itemspec.go +++ b/internal/items/itemspec.go @@ -220,6 +220,45 @@ type ItemSpec struct { KeyLockId string `yaml:"keylockid,omitempty"` // Example: `778-north` - If it's a key, what lock does it open? roomid-exitname etc. } +// AllEquipSlots returns every equipment slot ItemType in canonical display order. +// This is the single authoritative definition used by the characters, races, +// and combat packages to avoid per-package slot-list duplication. +func AllEquipSlots() []ItemType { + return []ItemType{ + Weapon, + Offhand, + Head, + Neck, + Body, + Belt, + Gloves, + Ring, + Legs, + Feet, + } +} + +// WeaponSlots returns the slots that hold weapons. +func WeaponSlots() []ItemType { + return []ItemType{Weapon, Offhand} +} + +// ArmorSlots returns every equipment slot except Weapon — the slots that hold +// armor and wearable items providing passive protection or stat benefits. +func ArmorSlots() []ItemType { + return []ItemType{ + Offhand, + Head, + Neck, + Body, + Belt, + Gloves, + Ring, + Legs, + Feet, + } +} + func (i Element) String() string { return string(i) } diff --git a/internal/mobs/mobs.go b/internal/mobs/mobs.go index ac4affe75..b282e1920 100644 --- a/internal/mobs/mobs.go +++ b/internal/mobs/mobs.go @@ -181,16 +181,9 @@ func NewMobById(mobId MobId, homeRoomId int, forceLevel ...int) *Mob { } } - mob.Character.Equipment.Weapon.Validate() - mob.Character.Equipment.Offhand.Validate() - mob.Character.Equipment.Head.Validate() - mob.Character.Equipment.Neck.Validate() - mob.Character.Equipment.Body.Validate() - mob.Character.Equipment.Belt.Validate() - mob.Character.Equipment.Gloves.Validate() - mob.Character.Equipment.Ring.Validate() - mob.Character.Equipment.Legs.Validate() - mob.Character.Equipment.Feet.Validate() + for _, slot := range characters.AllSlots() { + mob.Character.Equipment.Get(slot).Validate() + } mob.Validate() mob.Character.Validate(true) diff --git a/internal/races/races.go b/internal/races/races.go index afb4554bf..99bc223a0 100644 --- a/internal/races/races.go +++ b/internal/races/races.go @@ -132,23 +132,11 @@ func (r *Race) Validate() error { func (r Race) GetEnabledSlots() []string { ret := []string{} - slots := []string{ - string(items.Weapon), - string(items.Offhand), - string(items.Head), - string(items.Neck), - string(items.Body), - string(items.Belt), - string(items.Gloves), - string(items.Ring), - string(items.Legs), - string(items.Feet), - } - - for _, slotName := range slots { + for _, slot := range items.AllEquipSlots() { + slotName := string(slot) add := true - for _, slot := range r.DisabledSlots { - if slotName == slot { + for _, disabled := range r.DisabledSlots { + if slotName == disabled { add = false break } diff --git a/internal/usercommands/inventory.panels.go b/internal/usercommands/inventory.panels.go index 21c140747..db6969513 100644 --- a/internal/usercommands/inventory.panels.go +++ b/internal/usercommands/inventory.panels.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/GoMudEngine/GoMud/internal/characters" "github.com/GoMudEngine/GoMud/internal/items" "github.com/GoMudEngine/GoMud/internal/templates" "github.com/GoMudEngine/GoMud/internal/term" @@ -28,30 +29,16 @@ func buildInventoryPanel(user *users.UserRecord, itemList []items.Item, searchin equipPanel := layout.Panel("equipment") equipPanel.SetLabelWidth(9) - equipSlots := []struct { - label string - item *items.Item - }{ - {`Weapon:`, &c.Equipment.Weapon}, - {`Offhand:`, &c.Equipment.Offhand}, - {`Head:`, &c.Equipment.Head}, - {`Neck:`, &c.Equipment.Neck}, - {`Body:`, &c.Equipment.Body}, - {`Belt:`, &c.Equipment.Belt}, - {`Gloves:`, &c.Equipment.Gloves}, - {`Ring:`, &c.Equipment.Ring}, - {`Legs:`, &c.Equipment.Legs}, - {`Feet:`, &c.Equipment.Feet}, - } - - for _, s := range equipSlots { - if s.item.IsDisabled() { + for _, slot := range characters.AllSlots() { + itm := c.Equipment.Get(slot) + if itm.IsDisabled() { continue } + label := characters.SlotLabel(slot) equipPanel.Add( - fmt.Sprintf(`%s`, s.label), - fmt.Sprintf(`%s`, s.label), - fmt.Sprintf(`%s`, s.item.NameComplex()), + fmt.Sprintf(`%s`, label), + fmt.Sprintf(`%s`, label), + fmt.Sprintf(`%s`, itm.NameComplex()), ) } diff --git a/internal/usercommands/status.panels.go b/internal/usercommands/status.panels.go index 7ccfbdd11..f9296047f 100644 --- a/internal/usercommands/status.panels.go +++ b/internal/usercommands/status.panels.go @@ -6,8 +6,8 @@ import ( "strings" "github.com/GoMudEngine/GoMud/internal/buffs" + "github.com/GoMudEngine/GoMud/internal/characters" "github.com/GoMudEngine/GoMud/internal/configs" - "github.com/GoMudEngine/GoMud/internal/items" "github.com/GoMudEngine/GoMud/internal/skills" "github.com/GoMudEngine/GoMud/internal/templates" "github.com/GoMudEngine/GoMud/internal/term" @@ -136,39 +136,22 @@ func statusBonuses(user *users.UserRecord) (bool, error) { sb.WriteString(``) sb.WriteString(term.CRLFStr) - type slotItem struct { - SlotName string - Item items.Item - } - - slots := []slotItem{ - {`Weapon`, user.Character.Equipment.Weapon}, - {`Offhand`, user.Character.Equipment.Offhand}, - {`Head`, user.Character.Equipment.Head}, - {`Neck`, user.Character.Equipment.Neck}, - {`Body`, user.Character.Equipment.Body}, - {`Belt`, user.Character.Equipment.Belt}, - {`Gloves`, user.Character.Equipment.Gloves}, - {`Ring`, user.Character.Equipment.Ring}, - {`Legs`, user.Character.Equipment.Legs}, - {`Feet`, user.Character.Equipment.Feet}, - } - sb.WriteString(term.CRLFStr) sb.WriteString(` ┌─ .:Equipment Bonuses ──────────────────────────────────────────────────────┐`) equipFound := false - for _, slot := range slots { - if slot.Item.ItemId < 1 { + for _, slot := range characters.AllSlots() { + itm := *user.Character.Equipment.Get(slot) + if itm.ItemId < 1 { continue } - spec := slot.Item.GetSpec() + spec := itm.GetSpec() if len(spec.StatMods) == 0 { continue } equipFound = true sb.WriteString(term.CRLFStr) - sb.WriteString(fmt.Sprintf(` %-8s %s`, slot.SlotName+`:`, slot.Item.DisplayName())) + sb.WriteString(fmt.Sprintf(` %-8s %s`, characters.SlotLabel(slot), itm.DisplayName())) sb.WriteString(term.CRLFStr) sb.WriteString(` `) sb.WriteString(formatStatMods(spec.StatMods)) diff --git a/internal/web/api_routes.go b/internal/web/api_routes.go index 9595ecf5f..fe917a161 100644 --- a/internal/web/api_routes.go +++ b/internal/web/api_routes.go @@ -33,6 +33,7 @@ func registerAdminAPIRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /admin/api/v1/statmods", doBasicAuth(RunWithMUDLocked(apiV1GetStatMods))) // Items + mux.HandleFunc("GET /admin/api/v1/items/equip-slots", doBasicAuth(RunWithMUDLocked(apiV1GetEquipSlots))) mux.HandleFunc("GET /admin/api/v1/items/types", doBasicAuth(RunWithMUDLocked(apiV1GetItemTypes))) mux.HandleFunc("GET /admin/api/v1/items/attack-messages", doBasicAuth(RunWithMUDLocked(apiV1GetItemAttackMessages))) mux.HandleFunc("GET /admin/api/v1/items/ranks/weapons", doBasicAuth(RunWithMUDLocked(apiV1GetItemRanksWeapons))) diff --git a/internal/web/api_v1_items.go b/internal/web/api_v1_items.go index d19022c39..fd0c4d087 100644 --- a/internal/web/api_v1_items.go +++ b/internal/web/api_v1_items.go @@ -42,6 +42,22 @@ func apiV1GetItemRanksArmor(w http.ResponseWriter, r *http.Request) { }) } +// GET /admin/api/v1/items/equip-slots +// Returns the ordered list of equipment slot names as strings, sourced from +// items.AllEquipSlots(). Admin pages use this to build slot UIs dynamically +// so they do not need to hard-code the slot list. +func apiV1GetEquipSlots(w http.ResponseWriter, r *http.Request) { + slots := items.AllEquipSlots() + names := make([]string, len(slots)) + for i, s := range slots { + names[i] = string(s) + } + writeJSON(w, http.StatusOK, APIResponse[[]string]{ + Success: true, + Data: names, + }) +} + // GET /admin/api/v1/items/types func apiV1GetItemTypes(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, APIResponse[map[string]any]{ diff --git a/modules/gmcp/AGENTS.md b/modules/gmcp/AGENTS.md index b3799559b..b552ab240 100644 --- a/modules/gmcp/AGENTS.md +++ b/modules/gmcp/AGENTS.md @@ -11,6 +11,7 @@ - Be careful with telnet negotiation versus WebSocket text-prefix behavior; those paths are related but not identical. - If a change affects client capability tracking or Mudlet-specific behavior, verify the caller assumptions in web client or term code too. - Prefer additive namespace changes over silently repurposing an existing payload. +- `GMCPCharModule_Payload_Inventory_Worn` is a `map[string]GMCPCharModule_Payload_Inventory_Item` keyed by slot name. It is populated by `buildWornPayload`, which iterates `items.AllEquipSlots()`. Adding or removing a slot in `internal/items/itemspec.go` is sufficient; no manual update to this module is needed. ## Verification diff --git a/modules/gmcp/gmcp.Char.go b/modules/gmcp/gmcp.Char.go index d1e17a0b7..592cf319c 100644 --- a/modules/gmcp/gmcp.Char.go +++ b/modules/gmcp/gmcp.Char.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/GoMudEngine/GoMud/internal/buffs" + "github.com/GoMudEngine/GoMud/internal/characters" "github.com/GoMudEngine/GoMud/internal/configs" "github.com/GoMudEngine/GoMud/internal/events" "github.com/GoMudEngine/GoMud/internal/items" @@ -617,18 +618,7 @@ func (g *GMCPCharModule) GetCharNode(user *users.UserRecord, gmcpModule string) }, }, - Worn: &GMCPCharModule_Payload_Inventory_Worn{ - Weapon: newInventory_Item(user.Character.Equipment.Weapon), - Offhand: newInventory_Item(user.Character.Equipment.Offhand), - Head: newInventory_Item(user.Character.Equipment.Head), - Neck: newInventory_Item(user.Character.Equipment.Neck), - Body: newInventory_Item(user.Character.Equipment.Body), - Belt: newInventory_Item(user.Character.Equipment.Belt), - Gloves: newInventory_Item(user.Character.Equipment.Gloves), - Ring: newInventory_Item(user.Character.Equipment.Ring), - Legs: newInventory_Item(user.Character.Equipment.Legs), - Feet: newInventory_Item(user.Character.Equipment.Feet), - }, + Worn: buildWornPayload(user.Character.Equipment), } // Fill the items list @@ -961,7 +951,7 @@ type GMCPCharModule_Enemy struct { // ///////////////// type GMCPCharModule_Payload_Inventory struct { Backpack *GMCPCharModule_Payload_Inventory_Backpack `json:"Backpack,omitempty"` - Worn *GMCPCharModule_Payload_Inventory_Worn `json:"Worn"` + Worn GMCPCharModule_Payload_Inventory_Worn `json:"Worn"` } type GMCPCharModule_Payload_Inventory_Backpack struct { @@ -974,17 +964,18 @@ type GMCPCharModule_Payload_Inventory_Backpack_Summary struct { Max int `json:"max,omitempty"` } -type GMCPCharModule_Payload_Inventory_Worn struct { - Weapon GMCPCharModule_Payload_Inventory_Item `json:"weapon,omitempty"` - Offhand GMCPCharModule_Payload_Inventory_Item `json:"offhand,omitempty"` - Head GMCPCharModule_Payload_Inventory_Item `json:"head,omitempty"` - Neck GMCPCharModule_Payload_Inventory_Item `json:"neck,omitempty"` - Body GMCPCharModule_Payload_Inventory_Item `json:"body,omitempty"` - Belt GMCPCharModule_Payload_Inventory_Item `json:"belt,omitempty"` - Gloves GMCPCharModule_Payload_Inventory_Item `json:"gloves,omitempty"` - Ring GMCPCharModule_Payload_Inventory_Item `json:"ring,omitempty"` - Legs GMCPCharModule_Payload_Inventory_Item `json:"legs,omitempty"` - Feet GMCPCharModule_Payload_Inventory_Item `json:"feet,omitempty"` +// GMCPCharModule_Payload_Inventory_Worn is keyed by slot name (e.g. "weapon", +// "head") and serialises to the same JSON object shape that the named-struct +// version produced. Using a map means the field set tracks items.AllEquipSlots() +// automatically; no manual update is needed when slots are added or removed. +type GMCPCharModule_Payload_Inventory_Worn map[string]GMCPCharModule_Payload_Inventory_Item + +func buildWornPayload(eq characters.Worn) GMCPCharModule_Payload_Inventory_Worn { + worn := make(GMCPCharModule_Payload_Inventory_Worn, len(items.AllEquipSlots())) + for _, slot := range items.AllEquipSlots() { + worn[string(slot)] = newInventory_Item(*eq.Get(slot)) + } + return worn } type GMCPCharModule_Payload_Inventory_Item struct {