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
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+{{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})
+}