diff --git a/_datafiles/html/admin/mobs.html b/_datafiles/html/admin/mobs.html index d40cd848b..7ee3a22a8 100644 --- a/_datafiles/html/admin/mobs.html +++ b/_datafiles/html/admin/mobs.html @@ -346,6 +346,7 @@

Mob Editor

+
@@ -361,6 +362,11 @@

Mob Editor

+
@@ -464,6 +470,15 @@

Mob Editor

Items the mob carries
+
Spellbook
+ +
+ +
+ +
Spells this mob knows. Value indicates cast count (positive = enabled, negative = disabled).
+
+
Shop
@@ -723,6 +738,14 @@

Mob Editor

setVal('f-description', ch.Description || ''); setVal('f-raceid', ch.RaceId || 0); applyRaceEquipDisabled(ch.RaceId || 0); + var formWrap = document.getElementById('f-formrace-wrap'); + if (ch.FormRaceId > 0) { + var fr = allRaces[String(ch.FormRaceId)]; + document.getElementById('f-formrace').value = fr ? fr.Name : 'Unknown (#' + ch.FormRaceId + ')'; + formWrap.style.display = ''; + } else { + formWrap.style.display = 'none'; + } setVal('f-level', ch.Level || 1); setVal('f-hostile', mob.Hostile ? 'true' : 'false'); @@ -753,6 +776,9 @@

Mob Editor

// Buff IDs renderBuffChips('buffids-list', mob.BuffIds || []); + // Spellbook + renderSpellChips('spellbook-list', ch.SpellBook || {}); + // Stats const st = ch.Stats || {}; setVal('f-stat-strength', (st.Strength && st.Strength.Training) || 0); @@ -891,6 +917,53 @@

Mob Editor

return Array.from(document.querySelectorAll('#' + listId + ' .buff-chip')).map(c => parseInt(c.dataset.id, 10)); } + // ------------------------------------------------------------------------- + // Spell chips (spellbook) + // ------------------------------------------------------------------------- + function renderSpellChips(listId, spellBook) { + const list = document.getElementById(listId); + list.innerHTML = ''; + for (const [id, val] of Object.entries(spellBook)) { + appendSpellChip(list, id, val); + } + } + + function appendSpellChip(list, spellId, val) { + const chip = document.createElement('span'); + chip.className = 'buff-chip'; + chip.dataset.id = spellId; + chip.dataset.val = val; + const label = esc(spellId); + chip.innerHTML = `${label} `; + list.appendChild(chip); + } + + window.pickSpell = function () { + const listEl = document.getElementById('spellbook-list'); + const existing = collectSpellBook(); + const existingIds = Object.keys(existing); + Picker.open({ + ...PickerConfigs.spells, + multi: true, + selected: existingIds, + onSelect: (spells) => { + listEl.innerHTML = ''; + for (const spell of spells) { + const prev = existing[spell.SpellId]; + appendSpellChip(listEl, spell.SpellId, prev !== undefined ? prev : 1); + } + }, + }); + }; + + function collectSpellBook() { + const book = {}; + document.querySelectorAll('#spellbook-list .buff-chip').forEach(c => { + book[c.dataset.id] = parseInt(c.dataset.val, 10) || 1; + }); + return book; + } + // ------------------------------------------------------------------------- // Inventory items // ------------------------------------------------------------------------- @@ -1242,8 +1315,9 @@

Mob Editor

Legs: eqSlot('f-eq-legs'), Feet: eqSlot('f-eq-feet'), }, - Items: collectItems(), - Shop: collectShop(), + Items: collectItems(), + Shop: collectShop(), + SpellBook: collectSpellBook(), }, }; diff --git a/_datafiles/html/admin/spells-api.html b/_datafiles/html/admin/spells-api.html new file mode 100644 index 000000000..8250f38cb --- /dev/null +++ b/_datafiles/html/admin/spells-api.html @@ -0,0 +1,199 @@ +{{template "header" .}} + + + +

Spells API Reference

+

REST endpoints for browsing, editing, creating, and deleting spell definitions.

+ +
+ +
+ + GET + /admin/api/v2/spells + Return all spell definitions + +
+

Returns an array of all spell specs with a HasScript boolean.

+
curl -s -u admin:password \ + http://{{.CONFIG.FilePaths.WebDomain}}/admin/api/v2/spells
+
+
+ 200 Success +
{"success":true,"data":[{"SpellId":"fireball","Name":"Fireball","Type":"harmsingle","School":"conjuration","Cost":10,"WaitRounds":3,"Difficulty":0,"HasScript":true}]}
+
+
+
+
+ +
+ + GET + /admin/api/v2/spells/{spellId} + Get a single spell + +
+

{spellId} is the spell's string identifier.

+
curl -s -u admin:password \ + http://{{.CONFIG.FilePaths.WebDomain}}/admin/api/v2/spells/fireball
+
+
+ 200 Success +
{"success":true,"data":{"SpellId":"fireball","Name":"Fireball","Type":"harmsingle","School":"conjuration","Cost":10,"WaitRounds":3,"Difficulty":0}}
+
+
+ 404 Not Found +
{"success":false,"error":"spell not found: fireball"}
+
+
+
+
+ +
+ + POST + /admin/api/v2/spells + Create a new spell + +
+

SpellId is required and must be unique.

+
curl -s -u admin:password \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"SpellId":"icebolt","Name":"Ice Bolt","Type":"harmsingle","School":"conjuration","Cost":8}' \ + http://{{.CONFIG.FilePaths.WebDomain}}/admin/api/v2/spells
+
+
+ 201 Created +
{"success":true,"data":{"SpellId":"icebolt","Name":"Ice Bolt","Type":"harmsingle","School":"conjuration","Cost":8,"WaitRounds":0,"Difficulty":0}}
+
+
+ 409 Conflict +
{"success":false,"error":"spell already exists: icebolt"}
+
+
+
+
+ +
+ + PATCH + /admin/api/v2/spells/{spellId} + Update spell properties + +
+

Merges the supplied fields into the existing spec. The SpellId in the body is ignored.

+
curl -s -u admin:password \ + -X PATCH \ + -H "Content-Type: application/json" \ + -d '{"Cost":15,"Difficulty":10}' \ + http://{{.CONFIG.FilePaths.WebDomain}}/admin/api/v2/spells/fireball
+
+
+ 200 Success +
{"success":true,"data":{"SpellId":"fireball","Name":"Fireball","Cost":15,"Difficulty":10,...}}
+
+
+
+
+ +
+ + DELETE + /admin/api/v2/spells/{spellId} + Delete a spell and its script + +
+

Permanently removes the spell YAML and associated JS script from disk and from the in-memory cache.

+
curl -s -u admin:password \ + -X DELETE \ + http://{{.CONFIG.FilePaths.WebDomain}}/admin/api/v2/spells/fireball
+
+
+ 200 Success +
{"success":true}
+
+
+
+
+ +
+ + GET + /admin/api/v2/spells/{spellId}/script + Get the spell's JavaScript + +
+

Returns the raw script content. script is an empty string if no script file exists.

+
curl -s -u admin:password \ + http://{{.CONFIG.FilePaths.WebDomain}}/admin/api/v2/spells/fireball/script
+
+
+ 200 Success +
{"success":true,"data":{"script":"function onCast(actor, target){...}"}}
+
+
+
+
+ +
+ + PUT + /admin/api/v2/spells/{spellId}/script + Replace (or delete) the spell's JavaScript + +
+

Writes the provided script string to the spell's .js file. Send an empty string to delete the script file.

+
curl -s -u admin:password \ + -X PUT \ + -H "Content-Type: application/json" \ + -d '{"script":"function onCast(actor, target){}"}' \ + http://{{.CONFIG.FilePaths.WebDomain}}/admin/api/v2/spells/fireball/script
+
+
+ 200 Success +
{"success":true}
+
+
+
+
+ +
+ +{{template "footer" .}} diff --git a/_datafiles/html/admin/spells.html b/_datafiles/html/admin/spells.html new file mode 100644 index 000000000..3f6bc1921 --- /dev/null +++ b/_datafiles/html/admin/spells.html @@ -0,0 +1,446 @@ +{{template "header" .}} + + + +

Spells

+

Browse, edit, and create spell definitions. Click a spell in the list to load its editor.

+ +
+ +
+ + +
+
+ Select a spell from the list to edit it,
or click + New Spell to create one. +
+ +
+
+ + + +{{template "footer" .}} diff --git a/_datafiles/html/admin/static/js/picker-configs.js b/_datafiles/html/admin/static/js/picker-configs.js index 42bd69a6c..9bbfbf638 100644 --- a/_datafiles/html/admin/static/js/picker-configs.js +++ b/_datafiles/html/admin/static/js/picker-configs.js @@ -76,6 +76,20 @@ const PickerConfigs = { sort: (a, b) => a.MobId - b.MobId, }, + spells: { + title: 'Select Spell', + source: '/admin/api/v2/spells', + idKey: 'SpellId', + columns: [ + { key: 'SpellId', label: 'ID', width: '10rem', mono: true }, + { key: 'Name', label: 'Name', flex: true }, + { key: 'Type', label: 'Type', width: '8rem' }, + { key: 'School', label: 'School', width: '8rem' }, + ], + searchKeys: ['SpellId', 'Name'], + sort: (a, b) => a.Name.localeCompare(b.Name), + }, + mutators: { title: 'Select Mutator', source: '/admin/api/v1/mutators', diff --git a/_datafiles/html/admin/users.html b/_datafiles/html/admin/users.html index 8f66b6070..f8b881e91 100644 --- a/_datafiles/html/admin/users.html +++ b/_datafiles/html/admin/users.html @@ -238,6 +238,11 @@

User Editor

+
@@ -341,6 +346,15 @@

User Editor

Items the character is carrying
+
Spellbook
+ +
+ +
+ +
Spells this character knows. Value indicates cast count (positive = enabled, negative = disabled).
+
+
Shop
@@ -458,6 +472,14 @@

User Editor

setVal('f-name', ch.Name || ''); setVal('f-raceid', ch.RaceId || 0); applyRaceEquipDisabled(ch.RaceId || 0); + var formWrap = document.getElementById('f-formrace-wrap'); + if (ch.FormRaceId > 0) { + var fr = allRaces[String(ch.FormRaceId)]; + document.getElementById('f-formrace').value = fr ? fr.Name : 'Unknown (#' + ch.FormRaceId + ')'; + formWrap.style.display = ''; + } else { + formWrap.style.display = 'none'; + } setVal('f-description', ch.Description || ''); setVal('f-level', ch.Level || 1); setVal('f-gold', ch.Gold || 0); @@ -491,6 +513,9 @@

User Editor

// Inventory renderItemRows(ch.Items || []); + // Spellbook + renderSpellChips('spellbook-list', ch.SpellBook || {}); + // Shop renderShopEntries(ch.Shop || []); } @@ -609,6 +634,53 @@

User Editor

.map(c => ({ ItemId: parseInt(c.dataset.id, 10) })); } + // ------------------------------------------------------------------------- + // Spell chips (spellbook) + // ------------------------------------------------------------------------- + function renderSpellChips(listId, spellBook) { + const list = document.getElementById(listId); + list.innerHTML = ''; + for (const [id, val] of Object.entries(spellBook)) { + appendSpellChip(list, id, val); + } + } + + function appendSpellChip(list, spellId, val) { + const chip = document.createElement('span'); + chip.className = 'buff-chip'; + chip.dataset.id = spellId; + chip.dataset.val = val; + const label = esc(spellId); + chip.innerHTML = `${label} `; + list.appendChild(chip); + } + + window.pickSpell = function () { + const listEl = document.getElementById('spellbook-list'); + const existing = collectSpellBook(); + const existingIds = Object.keys(existing); + Picker.open({ + ...PickerConfigs.spells, + multi: true, + selected: existingIds, + onSelect: (spells) => { + listEl.innerHTML = ''; + for (const spell of spells) { + const prev = existing[spell.SpellId]; + appendSpellChip(listEl, spell.SpellId, prev !== undefined ? prev : 1); + } + }, + }); + }; + + function collectSpellBook() { + const book = {}; + document.querySelectorAll('#spellbook-list .buff-chip').forEach(c => { + book[c.dataset.id] = parseInt(c.dataset.val, 10) || 1; + }); + return book; + } + // ------------------------------------------------------------------------- // Shop entries (shared logic with mobs page) // ------------------------------------------------------------------------- @@ -898,8 +970,9 @@

User Editor

Legs: eqSlot('f-eq-legs'), Feet: eqSlot('f-eq-feet'), }, - Items: collectItems(), - Shop: collectShop(), + Items: collectItems(), + Shop: collectShop(), + SpellBook: collectSpellBook(), }, }; diff --git a/_datafiles/world/default/buffs/41-form_change.js b/_datafiles/world/default/buffs/41-form_change.js new file mode 100644 index 000000000..5449a72ff --- /dev/null +++ b/_datafiles/world/default/buffs/41-form_change.js @@ -0,0 +1,20 @@ +function onStart(actor, triggersLeft) { + SendUserMessage(actor.UserId(), 'Your body shifts and transforms!'); + SendRoomMessage(actor.GetRoomId(), + actor.GetCharacterName(true) + ' transforms before your eyes!', + actor.UserId()); +} + +function onTrigger(actor, triggersLeft) { + if (triggersLeft == 5) { + SendUserMessage(actor.UserId(), 'You feel your form beginning to waver...'); + } +} + +function onEnd(actor, triggersLeft) { + actor.RevertFormChange(); + SendUserMessage(actor.UserId(), 'Your body shifts back to its original form.'); + SendRoomMessage(actor.GetRoomId(), + actor.GetCharacterName(true) + ' reverts to their true form!', + actor.UserId()); +} diff --git a/_datafiles/world/default/buffs/41-form_change.yaml b/_datafiles/world/default/buffs/41-form_change.yaml new file mode 100644 index 000000000..53a7a7ee2 --- /dev/null +++ b/_datafiles/world/default/buffs/41-form_change.yaml @@ -0,0 +1,6 @@ +buffid: 41 +name: Form Change +description: You have taken another form. +secret: false +triggerrate: 1 round +triggercount: 20 diff --git a/_datafiles/world/default/buffs/42-polymorphed.js b/_datafiles/world/default/buffs/42-polymorphed.js new file mode 100644 index 000000000..1a0299d0b --- /dev/null +++ b/_datafiles/world/default/buffs/42-polymorphed.js @@ -0,0 +1,18 @@ +function onStart(actor, triggersLeft) { + actor.SetAdjective("polymorphed", true); +} + +function onTrigger(actor, triggersLeft) { + if (triggersLeft == 5) { + SendUserMessage(actor.UserId(), 'You feel the polymorph weakening...'); + } +} + +function onEnd(actor, triggersLeft) { + actor.RevertFormChange(); + SendUserMessage(actor.UserId(), 'The polymorph fades and your body shifts back to normal.'); + SendRoomMessage(actor.GetRoomId(), + actor.GetCharacterName(true) + ' shimmers and reverts to their true form.', + actor.UserId()); + actor.SetAdjective("polymorphed", false); +} diff --git a/_datafiles/world/default/buffs/42-polymorphed.yaml b/_datafiles/world/default/buffs/42-polymorphed.yaml new file mode 100644 index 000000000..c5ff90393 --- /dev/null +++ b/_datafiles/world/default/buffs/42-polymorphed.yaml @@ -0,0 +1,6 @@ +buffid: 42 +name: Polymorphed +description: You have been polymorphed into another form! +secret: false +triggerrate: 1 round +triggercount: 20 diff --git a/_datafiles/world/default/keywords.yaml b/_datafiles/world/default/keywords.yaml index 83d8a608f..27df38a22 100644 --- a/_datafiles/world/default/keywords.yaml +++ b/_datafiles/world/default/keywords.yaml @@ -98,6 +98,7 @@ help: - backstab - brawling - bump + - changeform - dual-wield - tackle - disarm @@ -125,9 +126,11 @@ help: - build - command - copyover + - formset - item - grant - locate + - mob - modify - mute - paz @@ -140,7 +143,10 @@ help: - server - skillset - spawn + - spell - syslogs + - teleport + - unmute - zap - zone - visit diff --git a/_datafiles/world/default/spells/poly.js b/_datafiles/world/default/spells/poly.js new file mode 100644 index 000000000..e003b438e --- /dev/null +++ b/_datafiles/world/default/spells/poly.js @@ -0,0 +1,63 @@ +POLYMORPH_RACE_IDS = [5, 7, 14, 18]; + +POLYMORPH_BUFF_ID = 42; + +function onCast(sourceActor, targetActor) { + SendUserMessage(sourceActor.UserId(), + 'You begin weaving threads of transmutation magic...'); + SendRoomMessage(sourceActor.GetRoomId(), + sourceActor.GetCharacterName(true) + ' begins weaving threads of transmutation magic...', + sourceActor.UserId()); + return true; +} + +function onWait(sourceActor, targetActor) { + SendUserMessage(sourceActor.UserId(), + 'The transmutation magic intensifies...'); + SendRoomMessage(sourceActor.GetRoomId(), + sourceActor.GetCharacterName(true) + ' continues weaving transmutation magic...', + sourceActor.UserId()); +} + +function onMagic(sourceActor, targetActor) { + + roomId = sourceActor.GetRoomId(); + sourceUserId = sourceActor.UserId(); + sourceName = sourceActor.GetCharacterName(true); + targetUserId = targetActor.UserId(); + targetName = targetActor.GetCharacterName(true); + + raceId = POLYMORPH_RACE_IDS[UtilDiceRoll(1, POLYMORPH_RACE_IDS.length) - 1]; + + targetActor.ApplyFormChange(raceId); + + targetActor.GiveBuff(POLYMORPH_BUFF_ID, "spell"); + + newRace = targetActor.GetRace(); + + if (sourceUserId != targetUserId) { + + SendUserMessage(sourceUserId, + 'You unleash a bolt of transmutation magic at ' + targetName + + ', transforming them into a ' + newRace + '!'); + + SendRoomMessage(roomId, + sourceName + ' unleashes a bolt of transmutation magic at ' + targetName + + ', transforming them!', + sourceUserId, targetUserId); + + SendUserMessage(targetUserId, + sourceName + ' hits you with a bolt of transmutation magic! ' + + 'Your body twists and reshapes — you are now a ' + newRace + '!'); + + } else { + + SendUserMessage(sourceUserId, + 'You unleash the transmutation magic on yourself! ' + + 'Your body twists and reshapes — you are now a ' + newRace + '!'); + + SendRoomMessage(roomId, + sourceName + ' unleashes transmutation magic on themselves, transforming into a ' + newRace + '!', + sourceUserId); + } +} diff --git a/_datafiles/world/default/spells/poly.yaml b/_datafiles/world/default/spells/poly.yaml new file mode 100644 index 000000000..fdc6a768e --- /dev/null +++ b/_datafiles/world/default/spells/poly.yaml @@ -0,0 +1,8 @@ +spellid: poly +name: Polymorph +description: Transforms the target into a random creature. +type: harmsingle +school: illusion +cost: 25 +waitrounds: 2 +difficulty: 40 diff --git a/_datafiles/world/default/templates/admincommands/help/command.formset.template b/_datafiles/world/default/templates/admincommands/help/command.formset.template new file mode 100644 index 000000000..675abff77 --- /dev/null +++ b/_datafiles/world/default/templates/admincommands/help/command.formset.template @@ -0,0 +1,17 @@ +The formset command can be used in the following ways: + +formset [target] [race] - e.g. formset sammy elf +Changes the target's form to the specified race for ~5 minutes (default). + +formset [target] [race] [duration] - e.g. formset sammy elf long +Changes the target's form with a specified duration. + +Durations: + short - ~5 minutes of rounds (default) + medium - ~15 minutes of rounds + long - ~30 minutes of rounds + +formset [target] revert - e.g. formset sammy revert +Force-reverts the target back to their true form. + +Target must be a player or mob in the same room. diff --git a/_datafiles/world/default/templates/admincommands/help/command.spell.template b/_datafiles/world/default/templates/admincommands/help/command.spell.template new file mode 100644 index 000000000..d980e6eef --- /dev/null +++ b/_datafiles/world/default/templates/admincommands/help/command.spell.template @@ -0,0 +1,19 @@ +The spell command can be used in the following ways: + +spell list +Displays a list of all spells. + +spell list [searchstring] - e.g. spell list heal +Searches for spells matching the search string. + +spell create +Starts an interactive wizard to create a new spell. + +spell create [spellname] - e.g. spell create fireball +Starts the create wizard pre-populated with an existing spell's values (useful for cloning). + +spell give [username] [spellname] - e.g. spell give sammy polymorph +Grants a spell to an online player. + +spell take [username] [spellname] - e.g. spell take sammy polymorph +Removes a spell from an online player. diff --git a/_datafiles/world/default/templates/character/status.template b/_datafiles/world/default/templates/character/status.template index dad209e83..581fb77e1 100644 --- a/_datafiles/world/default/templates/character/status.template +++ b/_datafiles/world/default/templates/character/status.template @@ -7,7 +7,7 @@ {{- $mpDisplay := printf "%s" ( manaStr .Character.Mana .Character.ManaMax.Value 22 ) }} ┌─ .:Info ──────────────────────┐ ┌─ .:Attributes ───────────────────────────┐ │ Area: {{ printf "%-22s" .Character.Zone }}│ │ Strength: {{ printf "%-4d(%-3d)" .Character.Stats.Strength.Value (.Character.StatMod "strength") }} Vitality: {{ printf "%-4d(%-3d)" .Character.Stats.Vitality.Value (.Character.StatMod "vitality") }} │ - Race: {{ printf "%-22s" .Character.Race }} Speed: {{ printf "%-4d(%-3d)" .Character.Stats.Speed.Value (.Character.StatMod "speed") }} Mysticism: {{ printf "%-4d(%-3d)" .Character.Stats.Mysticism.Value (.Character.StatMod "mysticism") }} + Race: {{ printf "%-22s" (printf "%s (%s)" .Character.Race .Character.RaceSize) }} Speed: {{ printf "%-4d(%-3d)" .Character.Stats.Speed.Value (.Character.StatMod "speed") }} Mysticism: {{ printf "%-4d(%-3d)" .Character.Stats.Mysticism.Value (.Character.StatMod "mysticism") }} Level: {{ printf "%-22d" .Character.Level }} │ Smarts: {{ printf "%-4d(%-3d)" .Character.Stats.Smarts.Value (.Character.StatMod "smarts") }} Percept: {{ printf "%-4d(%-3d)" .Character.Stats.Perception.Value (.Character.StatMod "perception") }} │ Exp: {{ printf "%-22s" ( tnl .UserId ) }} └──────────────────────────────────────────┘ Health: {{ printf "%s" $hpDisplay }} ┌─ .:Wealth ────────┐ ┌─ .:Training ───────┐ diff --git a/_datafiles/world/default/templates/help/changeform.md b/_datafiles/world/default/templates/help/changeform.md new file mode 100644 index 000000000..145ade13b --- /dev/null +++ b/_datafiles/world/default/templates/help/changeform.md @@ -0,0 +1,18 @@ +# Help for ~changeform~ (skill) + +The ~changeform~ skill lets you temporarily take the form of another race, gaining their racial traits, stats, equipment restrictions, and appearance. + +While transformed, your experience scaling remains based on your true race. + +## Usage: + +~changeform [race]~ Transform into the named race. + +~changeform revert~ Revert to your true form early. + +## Levels: + +(Lvl 1) Transform into selectable races. Duration ~10 rounds. +(Lvl 2) Transform into selectable races. Duration ~20 rounds. +(Lvl 3) Transform into selectable races. Duration ~40 rounds. +(Lvl 4) Transform into all races. Duration ~80 rounds. diff --git a/_datafiles/world/empty/keywords.yaml b/_datafiles/world/empty/keywords.yaml index b51ece68e..27df38a22 100644 --- a/_datafiles/world/empty/keywords.yaml +++ b/_datafiles/world/empty/keywords.yaml @@ -24,7 +24,6 @@ help: - killstats - encumbrance - death - - character - pets - train - stat-train @@ -35,7 +34,6 @@ help: - shout - broadcast - whisper - - inbox shops: - appraise - bank @@ -100,6 +98,7 @@ help: - backstab - brawling - bump + - changeform - dual-wield - tackle - disarm @@ -127,12 +126,12 @@ help: - build - command - copyover - - + - formset - item - grant - locate + - mob - modify - - mudmail - mute - paz - prepare @@ -144,7 +143,10 @@ help: - server - skillset - spawn + - spell - syslogs + - teleport + - unmute - zap - zone - visit diff --git a/_datafiles/world/empty/templates/character/status.template b/_datafiles/world/empty/templates/character/status.template index dad209e83..581fb77e1 100644 --- a/_datafiles/world/empty/templates/character/status.template +++ b/_datafiles/world/empty/templates/character/status.template @@ -7,7 +7,7 @@ {{- $mpDisplay := printf "%s" ( manaStr .Character.Mana .Character.ManaMax.Value 22 ) }} ┌─ .:Info ──────────────────────┐ ┌─ .:Attributes ───────────────────────────┐ │ Area: {{ printf "%-22s" .Character.Zone }}│ │ Strength: {{ printf "%-4d(%-3d)" .Character.Stats.Strength.Value (.Character.StatMod "strength") }} Vitality: {{ printf "%-4d(%-3d)" .Character.Stats.Vitality.Value (.Character.StatMod "vitality") }} │ - Race: {{ printf "%-22s" .Character.Race }} Speed: {{ printf "%-4d(%-3d)" .Character.Stats.Speed.Value (.Character.StatMod "speed") }} Mysticism: {{ printf "%-4d(%-3d)" .Character.Stats.Mysticism.Value (.Character.StatMod "mysticism") }} + Race: {{ printf "%-22s" (printf "%s (%s)" .Character.Race .Character.RaceSize) }} Speed: {{ printf "%-4d(%-3d)" .Character.Stats.Speed.Value (.Character.StatMod "speed") }} Mysticism: {{ printf "%-4d(%-3d)" .Character.Stats.Mysticism.Value (.Character.StatMod "mysticism") }} Level: {{ printf "%-22d" .Character.Level }} │ Smarts: {{ printf "%-4d(%-3d)" .Character.Stats.Smarts.Value (.Character.StatMod "smarts") }} Percept: {{ printf "%-4d(%-3d)" .Character.Stats.Perception.Value (.Character.StatMod "perception") }} │ Exp: {{ printf "%-22s" ( tnl .UserId ) }} └──────────────────────────────────────────┘ Health: {{ printf "%s" $hpDisplay }} ┌─ .:Wealth ────────┐ ┌─ .:Training ───────┐ diff --git a/internal/buffs/buffs.go b/internal/buffs/buffs.go index f74d92b6d..084d7902b 100644 --- a/internal/buffs/buffs.go +++ b/internal/buffs/buffs.go @@ -171,7 +171,7 @@ func (bs *Buffs) Started(buffId int) { } } -func (bs *Buffs) AddBuff(buffId int, isPermanent bool) bool { +func (bs *Buffs) AddBuff(buffId int, isPermanent bool, triggerCountOverride ...int) bool { if buffInfo := GetBuffSpec(buffId); buffInfo != nil { newBuff := Buff{ @@ -181,6 +181,10 @@ func (bs *Buffs) AddBuff(buffId int, isPermanent bool) bool { TriggersLeft: buffInfo.TriggerCount, } + if len(triggerCountOverride) > 0 && triggerCountOverride[0] > 0 { + newBuff.TriggersLeft = triggerCountOverride[0] + } + if isPermanent { newBuff.TriggersLeft = TriggersLeftUnlimited newBuff.PermaBuff = true @@ -314,7 +318,8 @@ func (bs *Buffs) Prune() (prunedBuffs []*Buff) { func GetDurations(buff *Buff, spec *BuffSpec) (roundsLeft int, totalRounds int) { - totalRounds = spec.TriggerCount * spec.RoundInterval + totalRounds = buff.TriggersLeft * spec.RoundInterval + roundsLeft = totalRounds - return totalRounds - buff.RoundCounter, totalRounds + return roundsLeft, totalRounds } diff --git a/internal/buffs/buffs_test.go b/internal/buffs/buffs_test.go index 218ff1b9d..aac4b051a 100644 --- a/internal/buffs/buffs_test.go +++ b/internal/buffs/buffs_test.go @@ -20,56 +20,47 @@ func TestGetDurations(t *testing.T) { { name: "Normal case", args: args{ - buff: &Buff{RoundCounter: 2}, - spec: &BuffSpec{TriggerCount: 5, RoundInterval: 3}, + buff: &Buff{TriggersLeft: 5}, + spec: &BuffSpec{RoundInterval: 3}, }, - wantRounds: 13, // (5*3)-2 = 15-2 = 13 + wantRounds: 15, wantTotal: 15, }, { - name: "Zero rounds passed", + name: "One trigger left", args: args{ - buff: &Buff{RoundCounter: 0}, - spec: &BuffSpec{TriggerCount: 4, RoundInterval: 2}, + buff: &Buff{TriggersLeft: 1}, + spec: &BuffSpec{RoundInterval: 2}, }, - wantRounds: 8, - wantTotal: 8, + wantRounds: 2, + wantTotal: 2, }, { - name: "All rounds passed", + name: "Zero triggers left", args: args{ - buff: &Buff{RoundCounter: 12}, - spec: &BuffSpec{TriggerCount: 3, RoundInterval: 4}, + buff: &Buff{TriggersLeft: 0}, + spec: &BuffSpec{RoundInterval: 5}, }, - wantRounds: 0, // (3*4)-12 = 12-12 = 0 - wantTotal: 12, - }, - { - name: "RoundCounter greater than total", - args: args{ - buff: &Buff{RoundCounter: 10}, - spec: &BuffSpec{TriggerCount: 2, RoundInterval: 4}, - }, - wantRounds: -2, // (2*4)-10 = 8-10 = -2 - wantTotal: 8, + wantRounds: 0, + wantTotal: 0, }, { - name: "Zero trigger count", + name: "Zero round interval", args: args{ - buff: &Buff{RoundCounter: 1}, - spec: &BuffSpec{TriggerCount: 0, RoundInterval: 5}, + buff: &Buff{TriggersLeft: 4}, + spec: &BuffSpec{RoundInterval: 0}, }, - wantRounds: -1, // (0*5)-1 = 0-1 = -1 + wantRounds: 0, wantTotal: 0, }, { - name: "Zero round interval", + name: "Overridden trigger count", args: args{ - buff: &Buff{RoundCounter: 3}, - spec: &BuffSpec{TriggerCount: 4, RoundInterval: 0}, + buff: &Buff{TriggersLeft: 80}, + spec: &BuffSpec{TriggerCount: 20, RoundInterval: 1}, }, - wantRounds: -3, // (4*0)-3 = 0-3 = -3 - wantTotal: 0, + wantRounds: 80, + wantTotal: 80, }, } for _, tt := range tests { diff --git a/internal/characters/character.go b/internal/characters/character.go index 7d34be51e..b3010ca00 100644 --- a/internal/characters/character.go +++ b/internal/characters/character.go @@ -52,6 +52,7 @@ type Character struct { RoomIdOnReset int // The room they are sent to if their RoomId isn't found. Zone string // The zone the character is in. The folder the room can be located in too. RaceId int // Character race + FormRaceId int `yaml:"formraceid,omitempty"` // Temporary race override (0 = not transformed) Stats stats.Statistics // Character stats Level int // The level of the character Experience int // The experience of the character @@ -139,6 +140,16 @@ func New() *Character { // returns description unless description is a hash // which points to another description location. func (c *Character) GetDescription() string { + if c.FormRaceId > 0 { + trueRace := races.GetRace(c.RaceId) + formRace := races.GetRace(c.FormRaceId) + if trueRace != nil && formRace != nil { + return strings.ReplaceAll( + strings.ReplaceAll(c.Description, trueRace.Name, formRace.Name), + strings.ToLower(trueRace.Name), strings.ToLower(formRace.Name), + ) + } + } return c.Description } @@ -343,7 +354,7 @@ func (c *Character) SetKey(lockId string, sequence string) { func (c *Character) GetDefaultDiceRoll() (attacks int, dCount int, dSides int, bonus int, buffOnCrit []int) { // default racial - raceInfo := races.GetRace(c.RaceId) + raceInfo := races.GetRace(c.GetRaceId()) attacks = raceInfo.Damage.Attacks dCount = raceInfo.Damage.DiceCount @@ -414,6 +425,14 @@ func (c *Character) LearnSpell(spellName string) bool { return false } +func (c *Character) UnLearnSpell(spellName string) bool { + if _, ok := c.SpellBook[spellName]; !ok { + return false + } + delete(c.SpellBook, spellName) + return true +} + func (c *Character) GrantXP(xp int) (actualXP int, xpScale int) { if xp == 0 { @@ -466,7 +485,7 @@ func (c *Character) Charm(userId int, rounds int, expireCommand string) { } func (c *Character) KnowsFirstAid() bool { - if r := races.GetRace(c.RaceId); r != nil { + if r := races.GetRace(c.GetRaceId()); r != nil { return r.KnowsFirstAid } return false @@ -629,6 +648,12 @@ func (c *Character) GetAdjectives() []string { if c.HasBuffFlag(buffs.Poison) { retAdjectives = append(retAdjectives, `poisoned`) } + + if c.FormRaceId > 0 { + if r := races.GetRace(c.FormRaceId); r != nil { + retAdjectives = append(retAdjectives, strings.ToLower(r.Name)+` form`) + } + } // End dynamic adjectives retAdjectives = append(retAdjectives, c.Adjectives...) @@ -768,7 +793,7 @@ func (c *Character) HandsRequired(i items.Item) int { return iSpec.Hands } - raceInfo := races.GetRace(c.RaceId) + raceInfo := races.GetRace(c.GetRaceId()) if raceInfo.Size == races.Large { return 1 } @@ -1209,9 +1234,9 @@ func (c *Character) HasBuff(buffId int) bool { return c.Buffs.HasBuff(buffId) } -func (c *Character) AddBuff(buffId int, isPermanent bool) error { +func (c *Character) AddBuff(buffId int, isPermanent bool, triggerCountOverride ...int) error { buffId = int(math.Abs(float64(buffId))) - if !c.Buffs.AddBuff(buffId, isPermanent) { + if !c.Buffs.AddBuff(buffId, isPermanent, triggerCountOverride...) { return fmt.Errorf(`failed to add buff. target: "%s" buffId: %d`, c.Name, buffId) } c.Validate() @@ -1427,12 +1452,13 @@ func (c *Character) RecalculateStats() { beforeManaMax := c.ManaMax beforeStats := c.Stats - if raceInfo := races.GetRace(c.RaceId); raceInfo != nil { - c.TNLScale = raceInfo.TNLScale - // Safety check: ensure TNLScale is never 0 + if trueRaceInfo := races.GetRace(c.RaceId); trueRaceInfo != nil { + c.TNLScale = trueRaceInfo.TNLScale if c.TNLScale == 0 { c.TNLScale = 1.0 } + } + if raceInfo := races.GetRace(c.GetRaceId()); raceInfo != nil { c.Stats.Strength.Base = raceInfo.Stats.Strength.Base c.Stats.Speed.Base = raceInfo.Stats.Speed.Base c.Stats.Smarts.Base = raceInfo.Stats.Smarts.Base @@ -1639,7 +1665,7 @@ func (c *Character) Validate(recalcPermaBuffs ...bool) error { c.Equipment.Feet.Validate() // Done with validation - if raceInfo := races.GetRace(c.RaceId); raceInfo != nil { + if raceInfo := races.GetRace(c.GetRaceId()); raceInfo != nil { c.Equipment.EnableAll() @@ -1713,6 +1739,27 @@ func (c *Character) Validate(recalcPermaBuffs ...bool) error { } + if !c.Equipment.Weapon.IsDisabled() && c.Equipment.Weapon.ItemId > 0 { + weaponHands := c.HandsRequired(c.Equipment.Weapon) + offhandHands := 0 + if !c.Equipment.Offhand.IsDisabled() && c.Equipment.Offhand.ItemId > 0 { + offhandHands = c.HandsRequired(c.Equipment.Offhand) + if offhandHands < 1 { + offhandHands = 1 + } + } + if weaponHands+offhandHands > 2 { + if offhandHands > 0 { + c.StoreItem(c.Equipment.Offhand) + c.Equipment.Offhand = items.Item{} + } + if weaponHands > 2 { + c.StoreItem(c.Equipment.Weapon) + c.Equipment.Weapon = items.Item{} + } + } + } + if len(recalcPermaBuffs) > 0 && recalcPermaBuffs[0] { c.reapplyPermabuffs() } @@ -1720,13 +1767,53 @@ func (c *Character) Validate(recalcPermaBuffs ...bool) error { return nil } +func (c *Character) GetRaceId() int { + if c.FormRaceId > 0 { + return c.FormRaceId + } + return c.RaceId +} + func (c *Character) Race() string { - if r := races.GetRace(c.RaceId); r != nil { + if r := races.GetRace(c.GetRaceId()); r != nil { return r.Name } return `Ghostly Spirit` } +func (c *Character) RaceSize() string { + if r := races.GetRace(c.GetRaceId()); r != nil { + return string(r.Size) + } + return string(races.Medium) +} + +func (c *Character) IsFormChanged() bool { + return c.FormRaceId > 0 +} + +func (c *Character) ApplyFormChange(newRaceId int) []items.Item { + if races.GetRace(newRaceId) == nil { + return nil + } + + if c.IsFormChanged() { + c.RevertFormChange() + } + + c.FormRaceId = newRaceId + c.Validate(true) + + return nil +} + +func (c *Character) RevertFormChange() []items.Item { + c.FormRaceId = 0 + c.Validate(true) + + return nil +} + func (c *Character) UpdateAlignment(amt int) { newAlignment := int(c.Alignment) + amt if newAlignment < int(AlignmentMinimum) { @@ -2004,7 +2091,7 @@ func (c *Character) reapplyPermabuffs(removedItems ...items.Item) { } // Apply any buffs that come from a race - if rInfo := races.GetRace(c.RaceId); rInfo != nil { + if rInfo := races.GetRace(c.GetRaceId()); rInfo != nil { for _, buffId := range rInfo.BuffIds { buffIdCount[buffId] = 100 // Don't allow racial buffs to be removed, keep this number high } diff --git a/internal/combat/calculations.go b/internal/combat/calculations.go index d292b0a77..50fbf1d1b 100644 --- a/internal/combat/calculations.go +++ b/internal/combat/calculations.go @@ -206,7 +206,7 @@ func ChanceToTame(s *users.UserRecord, t *mobs.Mob) int { proficiencyModifier = MOD_SKILL_MAX } - raceInfo := races.GetRace(s.Character.RaceId) + raceInfo := races.GetRace(s.Character.GetRaceId()) sizeModifier := 0 switch raceInfo.Size { diff --git a/internal/combat/combat.go b/internal/combat/combat.go index cb7ddfb6b..c567c0d95 100644 --- a/internal/combat/combat.go +++ b/internal/combat/combat.go @@ -127,7 +127,7 @@ func GetWaitMessages(stepType items.Intensity, sourceChar *characters.Character, } tokenReplacements := map[items.TokenName]string{ - items.TokenItemName: races.GetRace(sourceChar.RaceId).UnarmedName, + items.TokenItemName: races.GetRace(sourceChar.GetRaceId()).UnarmedName, items.TokenSource: sourceChar.Name, items.TokenSourceType: string(sourceType) + `name`, items.TokenTarget: targetChar.Name, @@ -312,7 +312,7 @@ func calculateCombat(sourceChar characters.Character, targetChar characters.Char } // Set the default weapon info - raceInfo := races.GetRace(sourceChar.RaceId) + raceInfo := races.GetRace(sourceChar.GetRaceId()) weaponName := raceInfo.UnarmedName weaponSubType := items.Generic diff --git a/internal/mobcommands/aid.go b/internal/mobcommands/aid.go index cbaac8c1d..557f69c8c 100644 --- a/internal/mobcommands/aid.go +++ b/internal/mobcommands/aid.go @@ -13,7 +13,7 @@ import ( func Aid(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) { - raceInfo := races.GetRace(mob.Character.RaceId) + raceInfo := races.GetRace(mob.Character.GetRaceId()) if !raceInfo.KnowsFirstAid { mob.Command(`emote doesn't know first aid.`) diff --git a/internal/mobcommands/lookfortrouble.go b/internal/mobcommands/lookfortrouble.go index 2ad19e1df..7f726db0e 100644 --- a/internal/mobcommands/lookfortrouble.go +++ b/internal/mobcommands/lookfortrouble.go @@ -40,9 +40,9 @@ func LookForTrouble(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) continue } - raceInfo := races.GetRace(user.Character.RaceId) + raceInfo := races.GetRace(user.Character.GetRaceId()) if raceInfo == nil { - mudlog.Error("RaceError", "Not Found", user.Character.RaceId) + mudlog.Error("RaceError", "Not Found", user.Character.GetRaceId()) continue } @@ -114,7 +114,7 @@ func LookForTrouble(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) continue } - raceInfo := races.GetRace(mob.Character.RaceId) + raceInfo := races.GetRace(mob.Character.GetRaceId()) if mob.HatesMob(considerMob) || mob.HatesRace(raceInfo.Name) { possibleMobTargets = append(possibleMobTargets, considerMobInstanceId) diff --git a/internal/mobs/mobs.go b/internal/mobs/mobs.go index f4e183199..0beec2742 100644 --- a/internal/mobs/mobs.go +++ b/internal/mobs/mobs.go @@ -174,7 +174,7 @@ func NewMobById(mobId MobId, homeRoomId int, forceLevel ...int) *Mob { } if mob.Character.Alignment == 0 { - if raceInfo := races.GetRace(mob.Character.RaceId); raceInfo != nil { + if raceInfo := races.GetRace(mob.Character.GetRaceId()); raceInfo != nil { if raceInfo.DefaultAlignment != 0 { mob.Character.Alignment = raceInfo.DefaultAlignment } @@ -370,7 +370,7 @@ func (m *Mob) IsTameable() bool { if len(m.ScriptTag) > 0 { return false } - if r := races.GetRace(m.Character.RaceId); r != nil { + if r := races.GetRace(m.Character.GetRaceId()); r != nil { if !r.Tameable { return false } @@ -514,7 +514,7 @@ func (r *Mob) HatesMob(m *Mob) bool { return false // Can't hate exact same as self } - mRace := races.GetRace(m.Character.RaceId) + mRace := races.GetRace(m.Character.GetRaceId()) raceName := strings.ToLower(mRace.Name) for _, rGroup := range r.Groups { if rGroup == raceName { @@ -548,7 +548,7 @@ func (m *Mob) GetAngryCommand() string { } // default to race based actions - r := races.GetRace(m.Character.RaceId) + r := races.GetRace(m.Character.GetRaceId()) actionCt := len(r.AngryCommands) if actionCt > 0 { return r.AngryCommands[util.Rand(actionCt)] diff --git a/internal/scripting/actor_func.go b/internal/scripting/actor_func.go index 5b900a772..3aa413612 100644 --- a/internal/scripting/actor_func.go +++ b/internal/scripting/actor_func.go @@ -51,8 +51,35 @@ func (a ScriptActor) GetRace() string { return a.characterRecord.Race() } -func (a ScriptActor) GetSize() string { +func (a ScriptActor) GetTrueRace() string { if r := races.GetRace(a.characterRecord.RaceId); r != nil { + return r.Name + } + return `Ghostly Spirit` +} + +func (a ScriptActor) IsFormChanged() bool { + return a.characterRecord.IsFormChanged() +} + +func (a ScriptActor) ApplyFormChange(raceId int) bool { + if races.GetRace(raceId) == nil { + return false + } + a.characterRecord.ApplyFormChange(raceId) + return true +} + +func (a ScriptActor) RevertFormChange() bool { + if !a.characterRecord.IsFormChanged() { + return false + } + a.characterRecord.RevertFormChange() + return true +} + +func (a ScriptActor) GetSize() string { + if r := races.GetRace(a.characterRecord.GetRaceId()); r != nil { return string(r.Size) } return string(races.Medium) diff --git a/internal/skills/skills.go b/internal/skills/skills.go index a19ecfeb0..4ad4f278d 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -34,6 +34,7 @@ const ( Protection SkillTag = `protection` // TODO Tame SkillTag = `tame` // [LVL 1-4] Give mushroom to fairie in ROOM 558, train in ROOM 830 Trading SkillTag = `trading` // TODO + ChangeForm SkillTag = `changeform` // TODO ) var ( @@ -78,6 +79,7 @@ var ( "monster hunter": { Tame, Track, + ChangeForm, }, "sorcerer": { Cast, diff --git a/internal/spells/admin.go b/internal/spells/admin.go new file mode 100644 index 000000000..77794e234 --- /dev/null +++ b/internal/spells/admin.go @@ -0,0 +1,71 @@ +package spells + +import ( + "fmt" + "os" + + "github.com/GoMudEngine/GoMud/internal/configs" + "github.com/GoMudEngine/GoMud/internal/fileloader" +) + +func SaveSpellSpec(spec *SpellData) error { + if spec.SpellId == "" { + return fmt.Errorf("cannot save spell with empty SpellId") + } + + if err := spec.Validate(); err != nil { + return err + } + + saveModes := []fileloader.SaveOption{} + if configs.GetFilePathsConfig().CarefulSaveFiles { + saveModes = append(saveModes, fileloader.SaveCareful) + } + + if err := fileloader.SaveFlatFile[*SpellData](configs.GetFilePathsConfig().DataFiles.String()+`/spells`, spec, saveModes...); err != nil { + return err + } + + allSpells[spec.SpellId] = spec + return nil +} + +func DeleteSpellSpec(spellId string) error { + spec := GetSpell(spellId) + if spec == nil { + return fmt.Errorf("spell %q not found", spellId) + } + + basePath := configs.GetFilePathsConfig().DataFiles.String() + `/spells/` + + yamlPath := basePath + spec.Filepath() + if err := os.Remove(yamlPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing spell yaml: %w", err) + } + + scriptPath := spec.GetScriptPath() + if err := os.Remove(scriptPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing spell script: %w", err) + } + + delete(allSpells, spellId) + return nil +} + +func SaveSpellScript(spellId string, content string) error { + spec := GetSpell(spellId) + if spec == nil { + return fmt.Errorf("spell %q not found", spellId) + } + + scriptPath := spec.GetScriptPath() + + if content == "" { + if err := os.Remove(scriptPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing spell script: %w", err) + } + return nil + } + + return os.WriteFile(scriptPath, []byte(content), 0644) +} diff --git a/internal/suggestions/autocomplete.go b/internal/suggestions/autocomplete.go index 97466e548..e80f8510c 100644 --- a/internal/suggestions/autocomplete.go +++ b/internal/suggestions/autocomplete.go @@ -8,6 +8,7 @@ import ( "github.com/GoMudEngine/GoMud/internal/items" "github.com/GoMudEngine/GoMud/internal/keywords" "github.com/GoMudEngine/GoMud/internal/mobs" + "github.com/GoMudEngine/GoMud/internal/races" "github.com/GoMudEngine/GoMud/internal/rooms" "github.com/GoMudEngine/GoMud/internal/usercommands" "github.com/GoMudEngine/GoMud/internal/users" @@ -423,6 +424,41 @@ func GetAutoComplete(userId int, inputText string) []string { } } + } else if cmd == `formset` { + + if len(parts) == 2 { + if room := rooms.LoadRoom(user.Character.RoomId); room != nil { + for _, uid := range room.GetPlayers() { + if u := users.GetByUserId(uid); u != nil { + if strings.HasPrefix(strings.ToLower(u.Character.Name), targetName) { + result = append(result, u.Character.Name[targetNameLen:]) + } + } + } + for _, mobInstId := range room.GetMobs() { + if mob := mobs.GetInstance(mobInstId); mob != nil { + if strings.HasPrefix(strings.ToLower(mob.Character.Name), targetName) { + result = append(result, mob.Character.Name[targetNameLen:]) + } + } + } + } + } else if len(parts) >= 3 { + racePart := strings.ToLower(strings.Join(parts[2:], ` `)) + racePartLen := len(racePart) + for _, r := range races.GetRaces() { + rName := strings.ToLower(r.Name) + if strings.HasPrefix(rName, racePart) { + result = append(result, r.Name[racePartLen:]) + } + } + for _, opt := range []string{`revert`, `short`, `medium`, `long`} { + if strings.HasPrefix(opt, racePart) { + result = append(result, opt[racePartLen:]) + } + } + } + } if len(itemList) > 0 { diff --git a/internal/usercommands/admin.formset.go b/internal/usercommands/admin.formset.go new file mode 100644 index 000000000..190dba256 --- /dev/null +++ b/internal/usercommands/admin.formset.go @@ -0,0 +1,142 @@ +package usercommands + +import ( + "fmt" + "strings" + + "github.com/GoMudEngine/GoMud/internal/configs" + "github.com/GoMudEngine/GoMud/internal/events" + "github.com/GoMudEngine/GoMud/internal/mobs" + "github.com/GoMudEngine/GoMud/internal/races" + "github.com/GoMudEngine/GoMud/internal/rooms" + "github.com/GoMudEngine/GoMud/internal/templates" + "github.com/GoMudEngine/GoMud/internal/users" + "github.com/GoMudEngine/GoMud/internal/util" +) + +func FormSet(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) { + + args := util.SplitButRespectQuotes(rest) + + if len(args) < 2 { + infoOutput, _ := templates.Process("admincommands/help/command.formset", nil, user.UserId, user.UserId) + user.SendText(infoOutput) + return true, nil + } + + targetName := args[0] + + if strings.ToLower(args[len(args)-1]) == "revert" && len(args) == 2 { + + targetUserId, targetMobInstanceId := room.FindByName(targetName) + + if targetUserId > 0 { + if targetUser := users.GetByUserId(targetUserId); targetUser != nil { + if !targetUser.Character.IsFormChanged() { + user.SendText(fmt.Sprintf(`%s is not form-changed.`, targetUser.Character.Name)) + return true, nil + } + targetUser.Character.RevertFormChange() + targetUser.Character.RemoveBuff(41) + targetUser.Character.RemoveBuff(42) + user.SendText(fmt.Sprintf(`Reverted %s to their true form.`, targetUser.Character.Name)) + room.SendText( + fmt.Sprintf(`%s reverts to their true form!`, targetUser.Character.Name), + user.UserId, + ) + return true, nil + } + } + + if targetMobInstanceId > 0 { + if targetMob := mobs.GetInstance(targetMobInstanceId); targetMob != nil { + if !targetMob.Character.IsFormChanged() { + user.SendText(fmt.Sprintf(`%s is not form-changed.`, targetMob.Character.Name)) + return true, nil + } + targetMob.Character.RevertFormChange() + targetMob.Character.RemoveBuff(41) + targetMob.Character.RemoveBuff(42) + user.SendText(fmt.Sprintf(`Reverted %s to their true form.`, targetMob.Character.Name)) + room.SendText( + fmt.Sprintf(`%s reverts to their true form!`, targetMob.Character.Name), + user.UserId, + ) + return true, nil + } + } + + user.SendText(fmt.Sprintf(`Could not find "%s" in this room.`, targetName)) + return true, nil + } + + durationKey := "short" + + lastArg := strings.ToLower(args[len(args)-1]) + if lastArg == "short" || lastArg == "medium" || lastArg == "long" { + durationKey = lastArg + args = args[:len(args)-1] + } + + if len(args) < 2 { + user.SendText("You must specify a race.") + return true, nil + } + + raceName := strings.Join(args[1:], " ") + raceInfo, found := races.FindRace(raceName) + if !found { + user.SendText(fmt.Sprintf(`"%s" is not a valid race.`, raceName)) + return true, nil + } + + minutes := 5 + switch durationKey { + case "medium": + minutes = 15 + case "long": + minutes = 30 + } + duration := configs.GetTimingConfig().MinutesToRounds(minutes) + + targetUserId, targetMobInstanceId := room.FindByName(targetName) + + if targetUserId > 0 { + targetUser := users.GetByUserId(targetUserId) + if targetUser == nil { + user.SendText("Could not find that user.") + return true, nil + } + + targetUser.Character.ApplyFormChange(raceInfo.RaceId) + targetUser.Character.AddBuff(41, false, duration) + + user.SendText(fmt.Sprintf(`Changed %s into a %s for %d rounds (~%d min).`, targetUser.Character.Name, raceInfo.Name, duration, minutes)) + room.SendText( + fmt.Sprintf(`%s transforms into a %s!`, targetUser.Character.Name, raceInfo.Name), + user.UserId, + ) + return true, nil + } + + if targetMobInstanceId > 0 { + targetMob := mobs.GetInstance(targetMobInstanceId) + if targetMob == nil { + user.SendText("Could not find that mob.") + return true, nil + } + + targetMob.Character.ApplyFormChange(raceInfo.RaceId) + targetMob.Character.AddBuff(41, false, duration) + + user.SendText(fmt.Sprintf(`Changed %s into a %s for %d rounds (~%d min).`, targetMob.Character.Name, raceInfo.Name, duration, minutes)) + room.SendText( + fmt.Sprintf(`%s transforms into a %s!`, targetMob.Character.Name, raceInfo.Name), + user.UserId, + ) + return true, nil + } + + user.SendText(fmt.Sprintf(`Could not find "%s" in this room.`, targetName)) + return true, nil +} diff --git a/internal/usercommands/admin.spell.go b/internal/usercommands/admin.spell.go index d87b20dca..c056d3046 100644 --- a/internal/usercommands/admin.spell.go +++ b/internal/usercommands/admin.spell.go @@ -54,6 +54,47 @@ func Spell(rest string, user *users.UserRecord, room *rooms.Room, flags events.E return spell_List(strings.TrimSpace(rest[4:]), user, room, flags) } + if args[0] == `give` || args[0] == `take` { + if len(args) < 3 { + infoOutput, _ := templates.Process("admincommands/help/command.spell", nil, user.UserId) + user.SendText(infoOutput) + return true, nil + } + + targetUser := users.GetByCharacterName(args[1]) + if targetUser == nil { + user.SendText(fmt.Sprintf(`Could not find online user "%s".`, args[1])) + return true, nil + } + + spellName := strings.Join(args[2:], ` `) + spellData := spells.FindSpellByName(spellName) + if spellData == nil { + user.SendText(fmt.Sprintf(`Could not find spell "%s".`, spellName)) + return true, nil + } + + if args[0] == `give` { + if targetUser.Character.HasSpell(spellData.SpellId) { + user.SendText(fmt.Sprintf(`%s already knows %s.`, targetUser.Character.Name, spellData.Name)) + return true, nil + } + targetUser.Character.LearnSpell(spellData.SpellId) + user.SendText(fmt.Sprintf(`Granted %s to %s.`, spellData.Name, targetUser.Character.Name)) + } else { + if !targetUser.Character.HasSpell(spellData.SpellId) { + user.SendText(fmt.Sprintf(`%s does not know %s.`, targetUser.Character.Name, spellData.Name)) + return true, nil + } + targetUser.Character.UnLearnSpell(spellData.SpellId) + user.SendText(fmt.Sprintf(`Removed %s from %s.`, spellData.Name, targetUser.Character.Name)) + } + + return true, nil + } + + infoOutput, _ := templates.Process("admincommands/help/command.spell", nil, user.UserId) + user.SendText(infoOutput) return true, nil } diff --git a/internal/usercommands/experience.go b/internal/usercommands/experience.go index fb7fe8141..1406cdd80 100644 --- a/internal/usercommands/experience.go +++ b/internal/usercommands/experience.go @@ -25,7 +25,7 @@ func Experience(rest string, user *users.UserRecord, room *rooms.Room, flags eve startLevel := 1 endLevel := 25 - chartRace := user.Character.RaceId + chartRace := user.Character.GetRaceId() // xp chart elf 50 if len(args) > 1 { diff --git a/internal/usercommands/inventory.go b/internal/usercommands/inventory.go index 257bcece0..e37b83ee5 100644 --- a/internal/usercommands/inventory.go +++ b/internal/usercommands/inventory.go @@ -128,7 +128,7 @@ func Inventory(rest string, user *users.UserRecord, room *rooms.Room, flags even itemNamesFormatted = append(itemNamesFormatted, iNameFormatted) } - raceInfo := races.GetRace(user.Character.RaceId) + raceInfo := races.GetRace(user.Character.GetRaceId()) diceRoll := raceInfo.Damage.DiceRoll if user.Character.Equipment.Weapon.ItemId != 0 { diff --git a/internal/usercommands/skill.changeform.go b/internal/usercommands/skill.changeform.go new file mode 100644 index 000000000..62a7ce360 --- /dev/null +++ b/internal/usercommands/skill.changeform.go @@ -0,0 +1,103 @@ +package usercommands + +import ( + "errors" + "fmt" + "strings" + + "github.com/GoMudEngine/GoMud/internal/events" + "github.com/GoMudEngine/GoMud/internal/races" + "github.com/GoMudEngine/GoMud/internal/rooms" + "github.com/GoMudEngine/GoMud/internal/skills" + "github.com/GoMudEngine/GoMud/internal/users" +) + +/* +ChangeForm Skill +Level 1 - Transform into selectable races. Duration 10 rounds. +Level 2 - Transform into selectable races. Duration 20 rounds. +Level 3 - Transform into selectable races. Duration 40 rounds. +Level 4 - Transform into all races. Duration 80 rounds. +*/ +func ChangeForm(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) { + + skillLevel := user.Character.GetSkillLevel(skills.ChangeForm) + if skillLevel == 0 { + user.SendText("You don't know how to change form.") + return true, errors.New(`you don't know how to change form`) + } + + if rest == `` { + user.SendText(`Type help changeform for more information on the changeform skill.`) + return true, nil + } + + if user.Character.IsFormChanged() { + if strings.ToLower(rest) == `revert` { + user.Character.RevertFormChange() + + user.Character.RemoveBuff(41) + user.Character.RemoveBuff(42) + + user.SendText("Your body shifts back to its original form.") + room.SendText( + fmt.Sprintf(`%s reverts to their true form!`, user.Character.Name), + user.UserId, + ) + return true, nil + } + user.SendText(`You are already transformed. Type changeform revert to change back first.`) + return true, nil + } + + if !user.Character.TryCooldown(skills.ChangeForm.String(), "20 rounds") { + user.SendText( + fmt.Sprintf("You need to wait %d more rounds to use that skill again.", user.Character.GetCooldown(skills.ChangeForm.String())), + ) + return true, errors.New(`you're doing that too often`) + } + + targetRaceName := rest + raceInfo, found := races.FindRace(targetRaceName) + if !found { + user.SendText(fmt.Sprintf(`"%s" is not a valid race.`, targetRaceName)) + return true, errors.New(`invalid race`) + } + + if raceInfo.RaceId == user.Character.GetRaceId() { + user.SendText("You are already that race!") + return true, nil + } + + if skillLevel < 4 && !raceInfo.Selectable { + user.SendText("You don't have enough mastery to take that form.") + return true, nil + } + + duration := 0 + switch skillLevel { + case 1: + duration = 10 + case 2: + duration = 20 + case 3: + duration = 40 + case 4: + duration = 80 + } + + user.Character.ApplyFormChange(raceInfo.RaceId) + user.Character.AddBuff(41, false, duration) + + events.AddToQueue(events.SkillUsed{UserId: user.UserId, Skill: skills.ChangeForm}) + + user.SendText( + fmt.Sprintf(`Your body twists and reshapes — you are now a %s!`, raceInfo.Name), + ) + room.SendText( + fmt.Sprintf(`%s transforms before your eyes into a %s!`, user.Character.Name, raceInfo.Name), + user.UserId, + ) + + return true, nil +} diff --git a/internal/usercommands/skill.peep.go b/internal/usercommands/skill.peep.go index 1ee461463..d3f0c14f8 100644 --- a/internal/usercommands/skill.peep.go +++ b/internal/usercommands/skill.peep.go @@ -91,7 +91,7 @@ func Peep(rest string, user *users.UserRecord, room *rooms.Room, flags events.Ev itemNamesFormatted = append(itemNamesFormatted, iNameFormatted) } - raceInfo := races.GetRace(u.Character.RaceId) + raceInfo := races.GetRace(u.Character.GetRaceId()) diceRoll := raceInfo.Damage.DiceRoll if u.Character.Equipment.Weapon.ItemId != 0 { @@ -156,7 +156,7 @@ func Peep(rest string, user *users.UserRecord, room *rooms.Room, flags events.Ev itemNamesFormatted = append(itemNamesFormatted, iNameFormatted) } - raceInfo := races.GetRace(m.Character.RaceId) + raceInfo := races.GetRace(m.Character.GetRaceId()) diceRoll := raceInfo.Damage.DiceRoll if m.Character.Equipment.Weapon.ItemId != 0 { diff --git a/internal/usercommands/usercommands.go b/internal/usercommands/usercommands.go index 8518ab62c..0dad347e3 100644 --- a/internal/usercommands/usercommands.go +++ b/internal/usercommands/usercommands.go @@ -69,6 +69,7 @@ var ( `bump`: {Bump, false, false}, `buy`: {Buy, false, false}, `cast`: {Cast, false, false}, + `changeform`: {ChangeForm, false, false}, `cooldowns`: {Cooldowns, true, false}, `command`: {Command, false, true}, // Admin only `copyover`: {Copyover, true, true}, // Admin only @@ -84,6 +85,7 @@ var ( `experience`: {Experience, true, false}, `equip`: {Equip, false, false}, `flee`: {Flee, false, false}, + `formset`: {FormSet, false, true}, // Admin only `gearup`: {Gearup, false, false}, `get`: {Get, false, false}, `give`: {Give, false, false}, diff --git a/internal/web/admin_items.go b/internal/web/admin_items.go index 668149ab6..8e17ca661 100644 --- a/internal/web/admin_items.go +++ b/internal/web/admin_items.go @@ -123,6 +123,14 @@ func adminMobsAPI(w http.ResponseWriter, r *http.Request) { serveAdminTemplate(w, r, "mobs-api.html", nil) } +func adminSpells(w http.ResponseWriter, r *http.Request) { + serveAdminTemplate(w, r, "spells.html", nil) +} + +func adminSpellsAPI(w http.ResponseWriter, r *http.Request) { + serveAdminTemplate(w, r, "spells-api.html", nil) +} + func adminMutators(w http.ResponseWriter, r *http.Request) { serveAdminTemplate(w, r, "mutators.html", nil) } diff --git a/internal/web/admin_nav.go b/internal/web/admin_nav.go index bcc1ed626..950d50e64 100644 --- a/internal/web/admin_nav.go +++ b/internal/web/admin_nav.go @@ -89,6 +89,14 @@ func buildAdminNav() []WebNavItem { {Label: "API Docs", Target: "/admin/buffs-api"}, }, }, + { + Name: "Spells", + Target: "/admin/spells", + SubItems: []WebNavSub{ + {Label: "View / Edit", Target: "/admin/spells"}, + {Label: "API Docs", Target: "/admin/spells-api"}, + }, + }, { Name: "Quests", Target: "/admin/quests", diff --git a/internal/web/admin_routes.go b/internal/web/admin_routes.go index 24708383d..0c7bd7728 100644 --- a/internal/web/admin_routes.go +++ b/internal/web/admin_routes.go @@ -117,6 +117,13 @@ func registerAdminRoutes(mux *http.ServeMux) { doBasicAuth(adminStatsAPI), )) + mux.HandleFunc("GET /admin/spells", RunWithMUDLocked( + doBasicAuth(adminSpells), + )) + mux.HandleFunc("GET /admin/spells-api", RunWithMUDLocked( + doBasicAuth(adminSpellsAPI), + )) + mux.HandleFunc("GET /admin/audio", RunWithMUDLocked( doBasicAuth(adminAudio), )) diff --git a/internal/web/api_routes.go b/internal/web/api_routes.go index bce4ede6d..84eb6e346 100644 --- a/internal/web/api_routes.go +++ b/internal/web/api_routes.go @@ -252,4 +252,27 @@ func registerAdminAPIRoutes(mux *http.ServeMux) { mux.HandleFunc("DELETE /admin/api/v1/races/{raceId}", RunWithMUDLocked( doBasicAuth(apiV1DeleteRace), )) + + // Spells — script sub-route before wildcard {spellId} + mux.HandleFunc("GET /admin/api/v2/spells", RunWithMUDLocked( + doBasicAuth(apiV2GetSpells), + )) + mux.HandleFunc("POST /admin/api/v2/spells", RunWithMUDLocked( + doBasicAuth(apiV2CreateSpell), + )) + mux.HandleFunc("GET /admin/api/v2/spells/{spellId}/script", RunWithMUDLocked( + doBasicAuth(apiV2GetSpellScript), + )) + mux.HandleFunc("PUT /admin/api/v2/spells/{spellId}/script", RunWithMUDLocked( + doBasicAuth(apiV2PutSpellScript), + )) + mux.HandleFunc("GET /admin/api/v2/spells/{spellId}", RunWithMUDLocked( + doBasicAuth(apiV2GetSpell), + )) + mux.HandleFunc("PATCH /admin/api/v2/spells/{spellId}", RunWithMUDLocked( + doBasicAuth(apiV2PatchSpell), + )) + mux.HandleFunc("DELETE /admin/api/v2/spells/{spellId}", RunWithMUDLocked( + doBasicAuth(apiV2DeleteSpell), + )) } diff --git a/internal/web/api_v2_spells.go b/internal/web/api_v2_spells.go new file mode 100644 index 000000000..4e506d7d9 --- /dev/null +++ b/internal/web/api_v2_spells.go @@ -0,0 +1,156 @@ +package web + +import ( + "encoding/json" + "net/http" + + "github.com/GoMudEngine/GoMud/internal/spells" +) + +type spellListEntry struct { + spells.SpellData + HasScript bool `json:"HasScript"` +} + +// GET /admin/api/v2/spells +func apiV2GetSpells(w http.ResponseWriter, r *http.Request) { + allSpells := spells.GetAllSpells() + result := make([]spellListEntry, 0, len(allSpells)) + for _, s := range allSpells { + result = append(result, spellListEntry{ + SpellData: *s, + HasScript: s.GetScript() != "", + }) + } + writeJSON(w, http.StatusOK, APIResponse[[]spellListEntry]{ + Success: true, + Data: result, + }) +} + +// GET /admin/api/v2/spells/{spellId} +func apiV2GetSpell(w http.ResponseWriter, r *http.Request) { + spellId := r.PathValue("spellId") + spec := spells.GetSpell(spellId) + if spec == nil { + writeAPIError(w, http.StatusNotFound, "spell not found: "+spellId) + return + } + + writeJSON(w, http.StatusOK, APIResponse[*spells.SpellData]{ + Success: true, + Data: spec, + }) +} + +// POST /admin/api/v2/spells +func apiV2CreateSpell(w http.ResponseWriter, r *http.Request) { + var spec spells.SpellData + if err := json.NewDecoder(r.Body).Decode(&spec); err != nil { + writeAPIError(w, http.StatusBadRequest, "malformed request body: "+err.Error()) + return + } + + if spec.SpellId == "" { + writeAPIError(w, http.StatusBadRequest, "SpellId is required") + return + } + + if existing := spells.GetSpell(spec.SpellId); existing != nil { + writeAPIError(w, http.StatusConflict, "spell already exists: "+spec.SpellId) + return + } + + if err := spells.SaveSpellSpec(&spec); err != nil { + writeAPIError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, APIResponse[*spells.SpellData]{ + Success: true, + Data: &spec, + }) +} + +// PATCH /admin/api/v2/spells/{spellId} +func apiV2PatchSpell(w http.ResponseWriter, r *http.Request) { + spellId := r.PathValue("spellId") + existing := spells.GetSpell(spellId) + if existing == nil { + writeAPIError(w, http.StatusNotFound, "spell not found: "+spellId) + return + } + + updated := *existing + if err := json.NewDecoder(r.Body).Decode(&updated); err != nil { + writeAPIError(w, http.StatusBadRequest, "malformed request body: "+err.Error()) + return + } + + updated.SpellId = spellId + + if err := spells.SaveSpellSpec(&updated); err != nil { + writeAPIError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, APIResponse[*spells.SpellData]{ + Success: true, + Data: &updated, + }) +} + +// DELETE /admin/api/v2/spells/{spellId} +func apiV2DeleteSpell(w http.ResponseWriter, r *http.Request) { + spellId := r.PathValue("spellId") + if spells.GetSpell(spellId) == nil { + writeAPIError(w, http.StatusNotFound, "spell not found: "+spellId) + return + } + + if err := spells.DeleteSpellSpec(spellId); err != nil { + writeAPIError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, APIResponse[struct{}]{Success: true}) +} + +// GET /admin/api/v2/spells/{spellId}/script +func apiV2GetSpellScript(w http.ResponseWriter, r *http.Request) { + spellId := r.PathValue("spellId") + spec := spells.GetSpell(spellId) + if spec == nil { + writeAPIError(w, http.StatusNotFound, "spell not found: "+spellId) + return + } + + writeJSON(w, http.StatusOK, APIResponse[map[string]string]{ + Success: true, + Data: map[string]string{"script": spec.GetScript()}, + }) +} + +// PUT /admin/api/v2/spells/{spellId}/script +func apiV2PutSpellScript(w http.ResponseWriter, r *http.Request) { + spellId := r.PathValue("spellId") + if spells.GetSpell(spellId) == nil { + writeAPIError(w, http.StatusNotFound, "spell not found: "+spellId) + return + } + + var body struct { + Script string `json:"script"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeAPIError(w, http.StatusBadRequest, "malformed request body: "+err.Error()) + return + } + + if err := spells.SaveSpellScript(spellId, body.Script); err != nil { + writeAPIError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, APIResponse[struct{}]{Success: true}) +}