From 4a64092408c3ee3acd511e0e7d550ea144e9adda Mon Sep 17 00:00:00 2001 From: Volte6 <143822+Volte6@users.noreply.github.com> Date: Sat, 16 May 2026 18:05:54 -0700 Subject: [PATCH] pet scripting, pet attack messages, pet attack frequency, --- .../building/scripting/FUNCTIONS_ACTORS.md | 14 +- .../building/scripting/FUNCTIONS_PETS.md | 202 +++++++++++++ .../guides/building/scripting/README.md | 5 + .../building/scripting/SCRIPTING_PETS.md | 135 +++++++++ _datafiles/html/admin/pets-api.html | 158 ++++++++-- _datafiles/html/admin/pets.html | 107 ++++++- _datafiles/world/default/pets/cat.js | 14 + _datafiles/world/default/pets/cat.yaml | 1 + _datafiles/world/default/pets/dog.js | 16 + _datafiles/world/default/pets/dog.yaml | 1 + _datafiles/world/default/pets/mule.js | 14 + _datafiles/world/default/pets/mule.yaml | 1 + _datafiles/world/default/pets/owl.js | 14 + _datafiles/world/default/pets/owl.yaml | 1 + internal/hooks/NewRound_UserRoundTick.go | 6 + internal/pets/admin.go | 28 ++ internal/pets/pets.go | 24 ++ internal/scripting/actor_func.go | 10 +- internal/scripting/memory.go | 1 + internal/scripting/pet.go | 276 ++++++++++++++++++ internal/scripting/pet_func.go | 151 ++++++++++ internal/scripting/schema.go | 47 +++ internal/scripting/scripting.go | 3 + internal/usercommands/usercommands.go | 7 + internal/web/api_routes.go | 4 +- internal/web/api_v1_pets.go | 49 ++++ 26 files changed, 1245 insertions(+), 44 deletions(-) create mode 100644 _datafiles/guides/building/scripting/FUNCTIONS_PETS.md create mode 100644 _datafiles/guides/building/scripting/SCRIPTING_PETS.md create mode 100644 _datafiles/world/default/pets/cat.js create mode 100644 _datafiles/world/default/pets/dog.js create mode 100644 _datafiles/world/default/pets/mule.js create mode 100644 _datafiles/world/default/pets/owl.js create mode 100644 internal/scripting/pet.go create mode 100644 internal/scripting/pet_func.go diff --git a/_datafiles/guides/building/scripting/FUNCTIONS_ACTORS.md b/_datafiles/guides/building/scripting/FUNCTIONS_ACTORS.md index 45568d5cc..3124407ef 100644 --- a/_datafiles/guides/building/scripting/FUNCTIONS_ACTORS.md +++ b/_datafiles/guides/building/scripting/FUNCTIONS_ACTORS.md @@ -571,7 +571,19 @@ Returns the shorthand ID string to refer to the mob or player ( `@123` or `#122` Uncurses any objects the target has equipped ## [ActorObject.GetPet()](/internal/scripting/actor_func.go) -Returns the pet object for the actor, or null +Returns the [PetObject](FUNCTIONS_PETS.md) for this actor, or `null` if the actor has no pet. + +_Note: Only meaningful for user actors. Mob actors always return `null`._ + +_Note: The returned PetObject is a live reference. Mutations such as `SetName()`, `Feed()`, and `Starve()` take effect immediately on the owner's character data._ + +**Example:** +```javascript +var pet = actor.GetPet(); +if (pet !== null) { + room.SendText(pet.Name() + ' is here!'); +} +``` ## [ActorObject.GrantXP(xpAmt int, reason string)](/internal/scripting/actor_func.go) Gives experience points to the actor diff --git a/_datafiles/guides/building/scripting/FUNCTIONS_PETS.md b/_datafiles/guides/building/scripting/FUNCTIONS_PETS.md new file mode 100644 index 000000000..3b87d3fcc --- /dev/null +++ b/_datafiles/guides/building/scripting/FUNCTIONS_PETS.md @@ -0,0 +1,202 @@ +# PetObject + +PetObject represents a pet owned by a player. It is obtained by calling +[ActorObject.GetPet()](FUNCTIONS_ACTORS.md#actorobjectgetpet) and returns +`null` when the actor has no pet. + +The object is a **live reference** into the owner's character data. Mutations +take effect immediately and are persisted the next time the character is saved. + +- [PetObject](#petobject) + - [PetObject.Type() string](#petobjecttype-string) + - [PetObject.Name() string](#petobjectname-string) + - [PetObject.NameSimple() string](#petobjectnamesimple-string) + - [PetObject.SetName(name string)](#petobjectsetnamename-string) + - [PetObject.Level() int](#petobjectlevel-int) + - [PetObject.Food() string](#petobjectfood-string) + - [PetObject.FoodLevel() int](#petobjectfoodlevel-int) + - [PetObject.Feed()](#petobjectfeed) + - [PetObject.Starve()](#petobjectstarve) + - [PetObject.GetStatMod(statName string) int](#petobjectgetstatmodstatname-string-int) + - [PetObject.GetCapacity() int](#petobjectgetcapacity-int) + - [PetObject.ItemCount() int](#petobjectitemcount-int) + - [PetObject.HasScript() bool](#petobjecthasscript-bool) + +--- + +## [PetObject.Type() string](/internal/scripting/pet_func.go) +Returns the pet's type identifier, such as `"dog"`, `"cat"`, `"owl"`, or `"mule"`. + +**Example:** +```javascript +var pet = actor.GetPet(); +if (pet !== null && pet.Type() === 'dog') { + room.SendText(pet.NameSimple() + ' barks!'); +} +``` + +--- + +## [PetObject.Name() string](/internal/scripting/pet_func.go) +Returns the pet's full display name, including ANSI colour tags and any hunger +indicator such as `(Hungry)` or `(Starving)`. + +Use this when sending the name to players as part of room or character output. +Use [NameSimple()](#petobjectnamesimple-string) when you need a plain string +for comparisons or concatenation. + +--- + +## [PetObject.NameSimple() string](/internal/scripting/pet_func.go) +Returns the plain text name of the pet with no colour tags or hunger indicator. +Falls back to the type identifier if the player has not given the pet a custom +name. + +**Example:** +```javascript +var pet = actor.GetPet(); +if (pet !== null) { + room.SendText(pet.NameSimple() + ' trots along behind you.'); +} +``` + +--- + +## [PetObject.SetName(name string)](/internal/scripting/pet_func.go) +Renames the pet. The change is reflected immediately in `Name()` and +`NameSimple()`. + +Pass an empty string to clear the custom name and revert the display name to +the pet's type identifier. + +| Argument | Explanation | +| --- | --- | +| name | The new plain text name, or `""` to clear it. | + +**Example:** +```javascript +var pet = actor.GetPet(); +if (pet !== null && pet.NameSimple() === pet.Type()) { + pet.SetName('Biscuit'); + actor.SendText('You name your pet Biscuit.'); +} +``` + +--- + +## [PetObject.Level() int](/internal/scripting/pet_func.go) +Returns the pet's current level, from 1 (minimum) to 10 (maximum). + +Pet level increases when the pet is well-fed at the daily tick and decreases +when the pet is starving. + +--- + +## [PetObject.Food() string](/internal/scripting/pet_func.go) +Returns the pet's current hunger state as a human-readable string. + +| Value | Meaning | +| --- | --- | +| `"Starving"` | Food level 0 — pet will lose a level at the next daily tick | +| `"Hungry"` | Food level 1 | +| `"Satisfied"` | Food level 2 | +| `"Full"` | Food level 3 — pet will gain a level at the next daily tick | + +**Example:** +```javascript +var pet = actor.GetPet(); +if (pet !== null && pet.Food() === 'Starving') { + actor.SendText(pet.NameSimple() + ' looks at you with desperate eyes.'); +} +``` + +--- + +## [PetObject.FoodLevel() int](/internal/scripting/pet_func.go) +Returns the pet's raw hunger value: `0` (Starving) through `3` (Full). + +Useful when you need a numeric comparison rather than a string. + +**Example:** +```javascript +var pet = actor.GetPet(); +if (pet !== null && pet.FoodLevel() < 2) { + actor.SendText(pet.NameSimple() + ' nudges you hopefully.'); +} +``` + +--- + +## [PetObject.Feed()](/internal/scripting/pet_func.go) +Increases the pet's hunger level by one step, up to a maximum of 3 (Full). +Has no effect if the pet is already full. + +_Note: This does not consume any item from the player's inventory. Use it when +a script wants to feed the pet as a side-effect of some other action._ + +--- + +## [PetObject.Starve()](/internal/scripting/pet_func.go) +Decreases the pet's hunger level by one step, down to a minimum of 0 +(Starving). Has no effect if the pet is already starving. + +--- + +## [PetObject.GetStatMod(statName string) int](/internal/scripting/pet_func.go) +Returns the stat modifier the pet currently grants its owner for the named +stat. Returns `0` if the pet provides no modifier for that stat at its current +level. + +Stat modifiers are defined per ability level in the pet's YAML definition and +scale up as the pet levels. + +| Argument | Explanation | +| --- | --- | +| statName | A stat name such as `"strength"`, `"speed"`, `"smarts"`, `"vitality"`, `"mysticism"`, or `"perception"`. | + +**Example:** +```javascript +var pet = actor.GetPet(); +if (pet !== null) { + var speedBonus = pet.GetStatMod('speed'); + if (speedBonus > 0) { + actor.SendText(pet.NameSimple() + ' makes you feel quicker. (+' + speedBonus + ' speed)'); + } +} +``` + +--- + +## [PetObject.GetCapacity() int](/internal/scripting/pet_func.go) +Returns the number of items the pet can carry at its current level. Returns `0` +if the pet type has no carry ability (e.g. a cat or owl). + +**Example:** +```javascript +var pet = actor.GetPet(); +if (pet !== null && pet.GetCapacity() > 0) { + actor.SendText(pet.NameSimple() + ' can carry ' + pet.GetCapacity() + ' item(s).'); +} +``` + +--- + +## [PetObject.ItemCount() int](/internal/scripting/pet_func.go) +Returns the number of items the pet is currently carrying. + +**Example:** +```javascript +var pet = actor.GetPet(); +if (pet !== null) { + var free = pet.GetCapacity() - pet.ItemCount(); + actor.SendText(pet.NameSimple() + ' has ' + free + ' free slot(s).'); +} +``` + +--- + +## [PetObject.HasScript() bool](/internal/scripting/pet_func.go) +Returns `true` if this pet type has a script file on disk. + +Useful in generic scripts that want to check whether a pet will respond to +events before attempting to trigger them. diff --git a/_datafiles/guides/building/scripting/README.md b/_datafiles/guides/building/scripting/README.md index 7acda9267..18e5a0a02 100644 --- a/_datafiles/guides/building/scripting/README.md +++ b/_datafiles/guides/building/scripting/README.md @@ -14,6 +14,9 @@ See [Item Scripting](SCRIPTING_ITEMS.md) # Buff Scripting See [Buff Scripting](SCRIPTING_BUFFS.md) +# Pet Scripting +See [Pet Scripting](SCRIPTING_PETS.md) + # Spell Scripting See [Spell Scripting](SCRIPTING_SPELLS.md) @@ -27,6 +30,8 @@ See [Spell Scripting](SCRIPTING_SPELLS.md) [ItemObject Functions](FUNCTIONS_ITEMS.md) - Functions that query or alter item data. +[PetObject Functions](FUNCTIONS_PETS.md) - Functions that query or alter pet data. + [Utility Functions](FUNCTIONS_UTIL.md) - Helper and info functions. [Messaging Functions](FUNCTIONS_MESSAGING.md) - Helper and info functions. diff --git a/_datafiles/guides/building/scripting/SCRIPTING_PETS.md b/_datafiles/guides/building/scripting/SCRIPTING_PETS.md new file mode 100644 index 000000000..a880250d2 --- /dev/null +++ b/_datafiles/guides/building/scripting/SCRIPTING_PETS.md @@ -0,0 +1,135 @@ +# Pet Scripting + +Example scripts: +* [Dog pet script](/_datafiles/world/default/pets/scripts/dog.js) +* [Cat pet script](/_datafiles/world/default/pets/scripts/cat.js) +* [Owl pet script](/_datafiles/world/default/pets/scripts/owl.js) +* [Mule pet script](/_datafiles/world/default/pets/scripts/mule.js) + +## Script paths + +Pet scripts reside in the same directory as the pet's YAML definition file, +with `.js` replacing `.yaml` in the filename. + +For example, the `dog` pet type defined at: + +``` +_datafiles/world/default/pets/dog.yaml +``` + +loads its script from: + +``` +_datafiles/world/default/pets/dog.js +``` + +## Script scope + +Variables defined at the global scope of a pet script are shared across all +owners of that pet type. If you need to store data specific to a single owner, +use the owner's [SetTempData / GetTempData](FUNCTIONS_ACTORS.md#actorobjectsettempdatakey-string-value-any) +or [SetMiscCharacterData / GetMiscCharacterData](FUNCTIONS_ACTORS.md#actorobjectsetmisccharacterdatakey-string-value-any). + +## Script functions + +The following functions are invoked automatically when defined in a pet script: + +--- + +``` +function PetAct(pet PetObject, actor ActorObject, room RoomObject) { +} +``` + +`PetAct()` is called each round with a probability determined by the pet +type's `RoundActChance` property (0–100). If `RoundActChance` is 0 the +function is never called. If it is 100 it is called every round. + +`PetAct` is **not** called while the pet's owner is in combat. + +The chance is evaluated by the engine before the function is invoked, so +`PetAct` itself does not need a top-level probability check. Add your own +`RandInt` guard inside the function only if you want behaviour that fires +less often than the configured chance. + +There is no return value. + +| Argument | Explanation | +| --- | --- | +| pet | [PetObject](FUNCTIONS_PETS.md) — the pet. | +| actor | [ActorObject](FUNCTIONS_ACTORS.md) — the player who owns the pet. | +| room | [RoomObject](FUNCTIONS_ROOMS.md) — the room both are in. | + +**Example:** +```javascript +function PetAct(pet, actor, room) { + // ~5% chance per round to do something visible + if (RandInt(1, 100) <= 5) { + room.SendText(pet.NameSimple() + ' sniffs the air curiously.'); + } +} +``` + +--- + +``` +function onCommand(cmd string, rest string, pet PetObject, actor ActorObject, room RoomObject) { +} +``` + +`onCommand()` is called whenever the pet's owner types any command. + +Returning `true` halts further command processing (the command is consumed). +Returning `false` allows the command to continue through the normal pipeline. + +This fires after buff `onCommand` handlers and before item `onCommand` +handlers. + +| Argument | Explanation | +| --- | --- | +| cmd | The command word typed by the owner, such as `"look"` or `"attack"`. | +| rest | Everything entered after the command word (may be empty). | +| pet | [PetObject](FUNCTIONS_PETS.md) | +| actor | [ActorObject](FUNCTIONS_ACTORS.md) — the owner. | +| room | [RoomObject](FUNCTIONS_ROOMS.md) | + +**Example:** +```javascript +function onCommand(cmd, rest, pet, actor, room) { + if (cmd === 'attack') { + if (RandInt(1, 4) === 1) { + room.SendText(pet.NameSimple() + ' growls menacingly.'); + } + } + return false; +} +``` + +--- + +``` +function onCommand_{command}(rest string, pet PetObject, actor ActorObject, room RoomObject) { +} +``` + +`onCommand_{command}()` is called when the owner types the specific command +named after the underscore. + +If a specific handler is defined, the generic `onCommand()` will **not** fire +for that command. + +| Argument | Explanation | +| --- | --- | +| rest | Everything entered after the command word (may be empty). | +| pet | [PetObject](FUNCTIONS_PETS.md) | +| actor | [ActorObject](FUNCTIONS_ACTORS.md) — the owner. | +| room | [RoomObject](FUNCTIONS_ROOMS.md) | + +**Example:** +```javascript +// Called only when the owner types 'pet' (the pet command) +function onCommand_pet(rest, pet, actor, room) { + room.SendText(pet.NameSimple() + ' wags its tail happily.'); + return false; +} +``` diff --git a/_datafiles/html/admin/pets-api.html b/_datafiles/html/admin/pets-api.html index a2662d898..2bf2137dc 100644 --- a/_datafiles/html/admin/pets-api.html +++ b/_datafiles/html/admin/pets-api.html @@ -14,6 +14,7 @@ .method-get { background: var(--color-success-bg); color: var(--color-success-text); } .method-post { background: var(--color-info-bg); color: var(--color-info-text); } .method-patch { background: var(--color-warning-bg); color: var(--color-warning-text); } + .method-put { background: var(--color-warning-bg); color: var(--color-warning-text); } .method-delete { background: var(--color-error-bg); color: var(--color-error-text); } .api-path { font-family: monospace; font-size: 0.9rem; color: var(--color-primary); } .api-desc { font-size: 0.85rem; color: var(--color-text-muted); margin-left: auto; text-align: right; } @@ -42,13 +43,15 @@ .params-table td { padding: 0.4rem 0.6rem; border: 1px solid var(--color-border); vertical-align: top; } .params-table td:first-child { font-family: monospace; color: var(--color-primary); } .required { color: var(--color-error-text); font-size: 0.75rem; font-weight: 600; } + .section-label { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-subtle); margin: 1rem 0 0.4rem; }

Pets API Reference

-

REST endpoints for listing, creating, updating, and deleting pet type definitions. View / Edit

+

REST endpoints for listing, creating, updating, deleting, and scripting pet type definitions. View / Edit

+
GET @@ -65,21 +68,21 @@

Pets API Reference

{ "success": true, "data": { - "wolf": { - "type": "wolf", - "name": "Wolf", - "capacity": 0, - "damage": { "diceroll": "1d6", "attacks": 1 }, - "statmods": { "strength": 2 }, - "buffids": [12] + "dog": { + "type": "dog", + "roundactchance": 10, + "abilities": [ + { "levelgranted": 1, "combatchance": 10, "damage": { "diceroll": "1d2" } }, + { "levelgranted": 2, "combatchance": 11, "statmods": { "strength": 1 }, "damage": { "diceroll": "1d3" } } + ] }, "mule": { "type": "mule", - "name": "Mule", - "capacity": 5, - "damage": null, - "statmods": null, - "buffids": null + "roundactchance": 0, + "abilities": [ + { "levelgranted": 1, "capacity": 1 }, + { "levelgranted": 2, "capacity": 2 } + ] } } }
@@ -88,6 +91,7 @@

Pets API Reference

+
POST @@ -95,7 +99,7 @@

Pets API Reference

Create a new pet type
-

Creates a new pet type definition. The type field is the unique identifier and is lowercased automatically. Returns 409 if the type already exists.

+

Creates a new pet type definition. The type field is the unique identifier and is lowercased automatically. Returns 400 if the type already exists.

@@ -103,7 +107,17 @@

Pets API Reference

- + +
FieldTypeDescription
type requiredstringUnique pet type identifier (e.g. wolf). Lowercased on save.
namestringDisplay name shown when the player has not named their pet.
namestylestringOptional color pattern applied to the pet name (e.g. :rainbow).
abilitiesobject[]Array of ability definitions. Each ability has levelgranted, combatchance, damage, attackmessages, statmods, buffids, and capacity. attackmessages is only meaningful when damage is set; fields: toowner, totarget, toroom, miss. When damage is set, combatchance is enforced to be at least 1.
roundactchanceint0–100 chance per round that PetAct() is invoked. Defaults to 0 (disabled). Not called while the owner is in combat.
abilitiesobject[] + Array of ability objects. Each entry may contain:
+ • levelgranted (int) — pet level at which this ability unlocks (1–10)
+ • combatchance (int, 0–100) — percent chance per combat round the pet attacks
+ • damage ({diceroll: string}) — damage dice roll (e.g. "1d6+2")
+ • attackmessages ({toowner, totarget, toroom, miss}) — optional custom combat messages; tokens: {petname}, {damage}, {targetname}
+ • statmods (object) — stat bonuses granted to the owner (e.g. {"strength": 2})
+ • buffids (int[]) — buff IDs permanently applied to the owner
+ • capacity (int) — number of items the pet can carry +
@@ -112,30 +126,32 @@

Pets API Reference

-d '{ "type": "wolf", "name": "Wolf", - "capacity": 0, - "damage": { "diceroll": "1d6", "attacks": 1 }, - "statmods": { "strength": 2 }, - "buffids": [12] + "roundactchance": 10, + "abilities": [ + { "levelgranted": 1, "combatchance": 15, "damage": { "diceroll": "1d6" }, "statmods": { "strength": 1 } }, + { "levelgranted": 5, "combatchance": 20, "damage": { "diceroll": "1d8" }, "statmods": { "strength": 3 } } + ] }' \ http://{{.CONFIG.FilePaths.WebDomain}}/admin/api/v1/pets
201 Created -
{"success":true,"data":{"type":"wolf","name":"Wolf","capacity":0,...}}
+
{"success":true,"data":{"type":"wolf","name":"Wolf","roundactchance":10,"abilities":[...]}}
- 400 Bad Request + 400 Bad Request — missing type
{"success":false,"error":"type is required"}
- 400 Conflict - type already exists + 400 Bad Request — type already exists
{"success":false,"error":"pet type \"wolf\" already exists"}
+
PATCH @@ -143,7 +159,7 @@

Pets API Reference

Update a pet type
-

{petname} is the pet type identifier (e.g. wolf). Fields in the request body overwrite the existing definition. The type is always preserved from the URL.

+

{petname} is the pet type identifier (e.g. wolf). The request body is merged over the existing definition. The type is always preserved from the URL.

@@ -152,29 +168,27 @@

Pets API Reference

ParameterInDescription
+

Request body accepts the same fields as POST: name, namestyle, roundactchance, abilities. The type field in the body is ignored; the URL value is always used.

+
curl -s -u admin:password -X PATCH \ -H "Content-Type: application/json" \ - -d '{ - "name": "Dire Wolf", - "capacity": 2, - "damage": { "diceroll": "2d6", "attacks": 2 }, - "statmods": { "strength": 4, "speed": 2 } -}' \ + -d '{"name":"Dire Wolf","roundactchance":15}' \ http://{{.CONFIG.FilePaths.WebDomain}}/admin/api/v1/pets/wolf
200 Success -
{"success":true,"data":{"type":"wolf","name":"Dire Wolf","capacity":2,...}}
+
{"success":true,"data":{"type":"wolf","name":"Dire Wolf","roundactchance":15,"abilities":[...]}}
404 Not Found -
{"success":false,"error":"pet type \"wolf\" not found"}
+
{"success":false,"error":"pet type not found: wolf"}
+
DELETE @@ -182,7 +196,7 @@

Pets API Reference

Delete a pet type
-

{petname} is the pet type identifier. Removes the YAML file from disk and unregisters the type from memory.

+

{petname} is the pet type identifier. Removes the YAML definition and any associated .js script file from disk, and unregisters the type from memory.

@@ -207,6 +221,86 @@

Pets API Reference

+ +
+ + GET + /admin/api/v1/pets/{petname}/script + Retrieve the script for a pet type + +
+

Returns the JavaScript source for the pet type's script file. The script lives alongside the pet's YAML definition (e.g. pets/wolf.js). Returns an empty string if no script file exists on disk.

+ +
ParameterInDescription
+ + + + +
ParameterInDescription
petname requiredpathPet type identifier.
+ +
curl -s -u admin:password \ + http://{{.CONFIG.FilePaths.WebDomain}}/admin/api/v1/pets/wolf/script
+ +
+
+ 200 Success +
{ + "success": true, + "data": { + "script": "function PetAct(pet, actor, room) {\n room.SendText(pet.NameSimple() + ' howls.');\n}\n" + } +}
+
+
+ 404 Not Found +
{"success":false,"error":"pet type not found: wolf"}
+
+
+
+
+ + +
+ + PUT + /admin/api/v1/pets/{petname}/script + Save or delete the script for a pet type + +
+

Writes the provided JavaScript to the pet's script file alongside its YAML definition (e.g. pets/wolf.js). Send an empty string to delete the script file. The in-memory VM cache for this pet type is invalidated automatically so the new script is used on the next invocation.

+ + + + + + + +
Parameter / FieldInDescription
petname requiredpathPet type identifier.
script requiredbody (JSON)JavaScript source string. Pass "" to remove the script file.
+ +
curl -s -u admin:password -X PUT \ + -H "Content-Type: application/json" \ + -d '{ + "script": "function PetAct(pet, actor, room) {\n room.SendText(pet.NameSimple() + \" howls at the moon.\");\n}\n" +}' \ + http://{{.CONFIG.FilePaths.WebDomain}}/admin/api/v1/pets/wolf/script
+ +
+
+ 200 Success +
{"success":true}
+
+
+ 400 Bad Request +
{"success":false,"error":"petname is required"}
+
+
+ 500 Internal Server Error +
{"success":false,"error":"..."}
+
+
+
+
+ {{template "footer" .}} diff --git a/_datafiles/html/admin/pets.html b/_datafiles/html/admin/pets.html index 24f6ee4e9..dcd52ee47 100644 --- a/_datafiles/html/admin/pets.html +++ b/_datafiles/html/admin/pets.html @@ -100,6 +100,25 @@ .ab-attack-msgs[open] > summary span::before { content: '\25BC\00A0'; } .ab-attack-msgs .form-grid { margin-top: 0.5rem; } + /* script editor */ + .script-toggle { + display: flex; align-items: center; gap: 0.6rem; cursor: pointer; + font-size: 0.85rem; color: var(--color-primary); font-weight: 600; padding: 0.5rem 0; + user-select: none; + } + .script-toggle::before { content: "\25B6"; font-size: 0.65rem; transition: transform 0.15s; } + .script-toggle.open::before { transform: rotate(90deg); } + .script-area { display: none; margin-top: 0.5rem; } + .script-area.open { display: block; } + .script-area textarea { + width: 100%; min-height: 480px; font-family: monospace; font-size: 0.82rem; + padding: 0.6rem 0.75rem; border: 1px solid var(--color-border-medium); border-radius: 4px; + resize: vertical; background: var(--color-primary); color: var(--color-code-text); line-height: 1.55; + } + .script-area textarea:focus { outline: 2px solid var(--color-accent-link); outline-offset: 1px; } + .script-hint { font-size: 0.75rem; color: var(--color-text-faint); margin-top: 0.3rem; } + .script-error { font-size: 0.78rem; color: var(--color-error-text); margin-top: 0.3rem; } + /* help modal */ .modal-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 1000; align-items: center; justify-content: center; } .modal-backdrop.open { display: flex; } @@ -159,6 +178,16 @@

Pets

Leave blank to use default. Prefix with : for a color pattern.
+
+ +
+ + 0% +
+
Chance per round (0–100%) that the pet’s PetAct() script function is called. Set to 0 to disable scripted ambient behaviour. Not called while the owner is in combat.
+
+
Abilities
@@ -166,6 +195,19 @@

Pets

Each ability unlocks at a given pet level. Pets level 1-10 based on hunger.
+ +
+
Script
+
+
+ +
+ +
+
Saved as a .js file alongside the pet’s .yaml definition. Clear the field and save to delete the script.
+
+
+
@@ -209,6 +251,7 @@

Combat Message Tokens

let petsData = {}; // type -> Pet let activeType = null; let isNew = false; + let scriptSync = null; const KNOWN_STATMODS = [ 'strength', 'speed', 'smarts', 'vitality', 'mysticism', 'perception', @@ -221,6 +264,7 @@

Combat Message Tokens

if (!res.ok) { showStatus('error', 'Failed to load pets: ' + res.error); return; } petsData = (res.data && res.data.data) || {}; renderList(); + scriptSync = ScriptEditor.init('f-script'); const hashType = decodeURIComponent(location.hash.slice(1)); if (hashType && petsData[hashType]) selectPet(hashType); } @@ -286,6 +330,31 @@

Combat Message Tokens

document.getElementById('fType').value = p.type || p.Type || ''; document.getElementById('fName').value = p.name || p.Name || ''; document.getElementById('fNameStyle').value = p.namestyle || p.NameStyle || ''; + const rac = p.roundactchance != null ? p.roundactchance : (p.RoundActChance != null ? p.RoundActChance : 0); + document.getElementById('fRoundActChance').value = rac; + document.getElementById('fRoundActChanceVal').textContent = rac + '%'; + + // Script + const script = p.GetScript ? p.GetScript() : (p._script || ''); + document.getElementById('f-script').value = script; + if (scriptSync) scriptSync(); + const hasScript = script.length > 0; + document.getElementById('script-toggle').classList.toggle('open', hasScript); + document.getElementById('script-area').classList.toggle('open', hasScript); + + // Load script from API for existing pets + if (!isNew && activeType) { + AdminAPI.get('/admin/api/v1/pets/' + encodeURIComponent(activeType) + '/script').then(res => { + if (res.ok) { + const scriptContent = (res.data && res.data.data && res.data.data.script) || ''; + document.getElementById('f-script').value = scriptContent; + if (scriptSync) scriptSync(); + const hasScript2 = scriptContent.length > 0; + document.getElementById('script-toggle').classList.toggle('open', hasScript2); + document.getElementById('script-area').classList.toggle('open', hasScript2); + } + }); + } // Abilities document.getElementById('abilitiesList').innerHTML = ''; @@ -581,10 +650,11 @@

Combat Message Tokens

}); return { - type: document.getElementById('fType').value.trim().toLowerCase(), - name: document.getElementById('fName').value.trim(), - namestyle: document.getElementById('fNameStyle').value.trim(), - abilities: abilities.length ? abilities : undefined, + type: document.getElementById('fType').value.trim().toLowerCase(), + name: document.getElementById('fName').value.trim(), + namestyle: document.getElementById('fNameStyle').value.trim(), + roundactchance: parseInt(document.getElementById('fRoundActChance').value, 10) || 0, + abilities: abilities.length ? abilities : undefined, }; } @@ -608,6 +678,16 @@

Combat Message Tokens

const btn = document.getElementById('btnSave'); btn.disabled = true; btn.textContent = 'Saving\u2026'; + const scriptContent = document.getElementById('f-script').value; + + if (scriptContent.trim().length > 0) { + const check = await ScriptWizard.validateScript(scriptContent); + if (!check.valid) { + btn.disabled = false; btn.textContent = 'Save'; + return; + } + } + let res; if (isNew) { res = await AdminAPI.post('/admin/api/v1/pets', payload); @@ -625,6 +705,11 @@

Combat Message Tokens

const saved = (res.data && res.data.data) || payload; const savedType = saved.type || saved.Type || payload.type; + const scriptRes = await AdminAPI.put('/admin/api/v1/pets/' + encodeURIComponent(savedType) + '/script', { script: scriptContent }); + if (!scriptRes.ok) { + showStatus('error', 'Pet saved but script update failed: ' + scriptRes.error); + } + petsData[savedType] = saved; if (isNew) { activeType = savedType; @@ -643,6 +728,20 @@

Combat Message Tokens

showStatus('success', 'Pet type "' + savedType + '" saved.'); }; + // ------------------------------------------------------------------------- + // Script toggle + // ------------------------------------------------------------------------- + window.toggleScript = function () { + const toggle = document.getElementById('script-toggle'); + const area = document.getElementById('script-area'); + toggle.classList.toggle('open'); + area.classList.toggle('open'); + }; + + window.openScriptWizard = function () { + ScriptWizard.open({ scriptType: 'pet', textareaId: 'f-script', syncFn: scriptSync }); + }; + window.deletePet = async function () { if (!activeType) return; if (!confirm('Delete pet type "' + activeType + '"? This cannot be undone.')) return; diff --git a/_datafiles/world/default/pets/cat.js b/_datafiles/world/default/pets/cat.js new file mode 100644 index 000000000..e31123fce --- /dev/null +++ b/_datafiles/world/default/pets/cat.js @@ -0,0 +1,14 @@ +// Cat pet script +// The cat is an aloof companion that occasionally deigns to acknowledge its owner. + +// PetAct is called approximately once every 10 rounds. +function PetAct(pet, actor, room) { + var actions = [ + pet.NameSimple() + ' grooms itself with practiced indifference.', + pet.NameSimple() + ' stares at something only it can see.', + pet.NameSimple() + ' flicks its tail once, slowly.', + pet.NameSimple() + ' stretches languidly and yawns.', + pet.NameSimple() + ' blinks at ' + actor.GetCharacterName(false) + ' with half-closed eyes.', + ]; + room.SendText(actions[RandInt(0, actions.length - 1)]); +} diff --git a/_datafiles/world/default/pets/cat.yaml b/_datafiles/world/default/pets/cat.yaml index 6c729af5d..e6c7a1a05 100644 --- a/_datafiles/world/default/pets/cat.yaml +++ b/_datafiles/world/default/pets/cat.yaml @@ -1,4 +1,5 @@ type: cat +roundactchance: 10 abilities: - levelgranted: 1 statmods: diff --git a/_datafiles/world/default/pets/dog.js b/_datafiles/world/default/pets/dog.js new file mode 100644 index 000000000..d8ec1aebb --- /dev/null +++ b/_datafiles/world/default/pets/dog.js @@ -0,0 +1,16 @@ +// Dog pet script +// The dog is a loyal companion that reacts to its owner's actions. + +// PetAct is called approximately once every 10 rounds. +// No top-level probability check is needed here; add one if you want +// behaviour that fires less frequently than that. +function PetAct(pet, actor, room) { + var actions = [ + pet.NameSimple() + ' wags its tail happily.', + pet.NameSimple() + ' sniffs around the room.', + pet.NameSimple() + ' sits at ' + actor.GetCharacterName(false) + "'s feet.", + pet.NameSimple() + ' lets out a soft woof.', + pet.NameSimple() + ' nudges ' + actor.GetCharacterName(false) + "'s hand.", + ]; + room.SendText(actions[RandInt(0, actions.length - 1)]); +} diff --git a/_datafiles/world/default/pets/dog.yaml b/_datafiles/world/default/pets/dog.yaml index 8a40e852e..5908d380f 100644 --- a/_datafiles/world/default/pets/dog.yaml +++ b/_datafiles/world/default/pets/dog.yaml @@ -1,4 +1,5 @@ type: dog +roundactchance: 10 abilities: - levelgranted: 1 combatchance: 10 diff --git a/_datafiles/world/default/pets/mule.js b/_datafiles/world/default/pets/mule.js new file mode 100644 index 000000000..2a619c77f --- /dev/null +++ b/_datafiles/world/default/pets/mule.js @@ -0,0 +1,14 @@ +// Mule pet script +// The mule is a sturdy, stubborn pack animal that occasionally makes its feelings known. + +// PetAct is called approximately once every 10 rounds. +function PetAct(pet, actor, room) { + var actions = [ + pet.NameSimple() + ' stamps a hoof impatiently.', + pet.NameSimple() + ' snorts and shakes its head.', + pet.NameSimple() + ' shifts its load and huffs.', + pet.NameSimple() + ' glances at ' + actor.GetCharacterName(false) + ' with a long-suffering expression.', + pet.NameSimple() + ' swishes its tail at a fly.', + ]; + room.SendText(actions[RandInt(0, actions.length - 1)]); +} diff --git a/_datafiles/world/default/pets/mule.yaml b/_datafiles/world/default/pets/mule.yaml index 40278bb69..38e91c68f 100644 --- a/_datafiles/world/default/pets/mule.yaml +++ b/_datafiles/world/default/pets/mule.yaml @@ -1,4 +1,5 @@ type: mule +roundactchance: 10 level: 1 abilities: - levelgranted: 1 diff --git a/_datafiles/world/default/pets/owl.js b/_datafiles/world/default/pets/owl.js new file mode 100644 index 000000000..f7cad5885 --- /dev/null +++ b/_datafiles/world/default/pets/owl.js @@ -0,0 +1,14 @@ +// Owl pet script +// The owl is a wise, observant companion that notices things others miss. + +// PetAct is called approximately once every 10 rounds. +function PetAct(pet, actor, room) { + var actions = [ + pet.NameSimple() + ' rotates its head and surveys the room with amber eyes.', + pet.NameSimple() + ' ruffles its feathers and settles back into stillness.', + pet.NameSimple() + ' emits a soft, low hoot.', + pet.NameSimple() + ' bobs its head, seemingly deep in thought.', + pet.NameSimple() + ' clicks its beak once.', + ]; + room.SendText(actions[RandInt(0, actions.length - 1)]); +} diff --git a/_datafiles/world/default/pets/owl.yaml b/_datafiles/world/default/pets/owl.yaml index be3ebbb7e..1c384ffa1 100644 --- a/_datafiles/world/default/pets/owl.yaml +++ b/_datafiles/world/default/pets/owl.yaml @@ -1,4 +1,5 @@ type: owl +roundactchance: 10 abilities: - levelgranted: 1 statmods: diff --git a/internal/hooks/NewRound_UserRoundTick.go b/internal/hooks/NewRound_UserRoundTick.go index 799540049..5bfbdde00 100644 --- a/internal/hooks/NewRound_UserRoundTick.go +++ b/internal/hooks/NewRound_UserRoundTick.go @@ -97,6 +97,12 @@ func UserRoundTick(e events.Event) events.ListenerReturn { user.Command(`zombieact`) } + // Fire PetAct script using the pet type's configured RoundActChance. + // Not called while the owner is in combat. + if user.Character.Pet.Exists() && user.Character.Aggro == nil && user.Character.Pet.RoundActChance > 0 && util.Rand(100) < user.Character.Pet.RoundActChance { + scripting.TryPetScriptEvent(`PetAct`, uId) + } + // Roundtick any cooldowns user.Character.Cooldowns.RoundTick() diff --git a/internal/pets/admin.go b/internal/pets/admin.go index de916da67..596d19635 100644 --- a/internal/pets/admin.go +++ b/internal/pets/admin.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strings" "github.com/GoMudEngine/GoMud/internal/configs" @@ -48,6 +49,10 @@ func DeletePetSpec(petType string) error { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("removing pet file: %w", err) } + scriptPath := p.GetScriptPath() + if err := os.Remove(scriptPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing pet script: %w", err) + } delete(petTypes, petType) return nil } @@ -63,3 +68,26 @@ func CreatePetSpec(p *Pet) error { } return SavePetSpec(p) } + +// SavePetScript writes (or removes) the JavaScript script file for a pet type. +func SavePetScript(petType string, content string) error { + petType = strings.ToLower(strings.TrimSpace(petType)) + p, ok := petTypes[petType] + if !ok { + return fmt.Errorf("pet type %q not found", petType) + } + + scriptPath := p.GetScriptPath() + + if content == "" { + if err := os.Remove(scriptPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing pet script: %w", err) + } + return nil + } + + if err := os.MkdirAll(filepath.Dir(scriptPath), os.ModePerm); err != nil { + return fmt.Errorf("creating pet scripts directory: %w", err) + } + return util.WriteFile(scriptPath, []byte(content), 0644) +} diff --git a/internal/pets/pets.go b/internal/pets/pets.go index 401d95bd7..7134c085a 100644 --- a/internal/pets/pets.go +++ b/internal/pets/pets.go @@ -2,6 +2,8 @@ package pets import ( "fmt" + "os" + "strings" "time" "github.com/GoMudEngine/GoMud/internal/buffs" @@ -20,6 +22,7 @@ type Pet struct { Name string `yaml:"name,omitempty"` // Name of the pet (player provided hopefully) NameStyle string `yaml:"namestyle,omitempty"` // Optional color pattern to apply Type string `yaml:"type"` // type of pet + RoundActChance int `yaml:"roundactchance,omitempty"` // 0-100 chance per round to fire PetAct script Food Food `yaml:"food,omitempty"` // how much food the pet has Level int `yaml:"level,omitempty"` // Pet level (1-10) LastMealRound uint8 `yaml:"lastmealround,omitempty"` // When the pet was last fed @@ -362,6 +365,26 @@ func (p *Pet) Id() string { return p.Type } +func (p *Pet) GetScriptPath() string { + return util.FilePath(configs.GetFilePathsConfig().DataFiles.String(), `/`, `pets`, `/`, strings.Replace(p.Filepath(), `.yaml`, `.js`, 1)) +} + +func (p *Pet) HasScript() bool { + scriptPath := p.GetScriptPath() + _, err := os.Stat(scriptPath) + return err == nil +} + +func (p *Pet) GetScript() string { + scriptPath := p.GetScriptPath() + if _, err := os.Stat(scriptPath); err == nil { + if bytes, err := util.ReadFile(scriptPath); err == nil { + return string(bytes) + } + } + return `` +} + func (p *Pet) Validate() error { if p.Items == nil { @@ -387,6 +410,7 @@ func (p *Pet) Validate() error { p.Abilities = make([]PetAbility, len(def.Abilities)) copy(p.Abilities, def.Abilities) p.NameStyle = def.NameStyle + p.RoundActChance = def.RoundActChance p.clearAbilityCache() } } diff --git a/internal/scripting/actor_func.go b/internal/scripting/actor_func.go index 3aa413612..056878689 100644 --- a/internal/scripting/actor_func.go +++ b/internal/scripting/actor_func.go @@ -9,7 +9,6 @@ import ( "github.com/GoMudEngine/GoMud/internal/configs" "github.com/GoMudEngine/GoMud/internal/events" "github.com/GoMudEngine/GoMud/internal/mobs" - "github.com/GoMudEngine/GoMud/internal/pets" "github.com/GoMudEngine/GoMud/internal/races" "github.com/GoMudEngine/GoMud/internal/rooms" "github.com/GoMudEngine/GoMud/internal/skills" @@ -711,12 +710,11 @@ func (a ScriptActor) Uncurse() []*ScriptItem { return retList } -func (a ScriptActor) GetPet() *pets.Pet { - - if a.characterRecord.Pet.Exists() { - return &a.characterRecord.Pet +func (a ScriptActor) GetPet() *ScriptPet { + if !a.characterRecord.Pet.Exists() { + return nil } - return nil + return GetPet(&a.characterRecord.Pet) } func (a ScriptActor) GrantXP(xpAmt int, reason string) { diff --git a/internal/scripting/memory.go b/internal/scripting/memory.go index 8cbec2053..66361285b 100644 --- a/internal/scripting/memory.go +++ b/internal/scripting/memory.go @@ -8,6 +8,7 @@ func GetMemoryUsage() map[string]util.MemoryResult { ret["roomVMCache"] = util.MemoryResult{Memory: util.MemoryUsage(roomVMCache), Count: len(roomVMCache)} ret["mobVMCache"] = util.MemoryResult{Memory: util.MemoryUsage(mobVMCache), Count: len(mobVMCache)} ret["itemVMCache"] = util.MemoryResult{Memory: util.MemoryUsage(itemVMCache), Count: len(itemVMCache)} + ret["petVMCache"] = util.MemoryResult{Memory: util.MemoryUsage(petVMCache), Count: len(petVMCache)} ret["buffVMCache"] = util.MemoryResult{Memory: util.MemoryUsage(buffVMCache), Count: len(buffVMCache)} ret["spellVMCache"] = util.MemoryResult{Memory: util.MemoryUsage(spellVMCache), Count: len(spellVMCache)} ret["moduleFunctions"] = util.MemoryResult{Memory: util.MemoryUsage(moduleFunctions), Count: len(moduleFunctions)} diff --git a/internal/scripting/pet.go b/internal/scripting/pet.go new file mode 100644 index 000000000..292d172d8 --- /dev/null +++ b/internal/scripting/pet.go @@ -0,0 +1,276 @@ +package scripting + +import ( + "errors" + "fmt" + "time" + + "github.com/GoMudEngine/GoMud/internal/mudlog" + "github.com/GoMudEngine/GoMud/internal/pets" + "github.com/GoMudEngine/GoMud/internal/users" + "github.com/dop251/goja" +) + +var ( + petVMCache = make(map[string]*VMWrapper) + scriptPetTimeout = 50 * time.Millisecond +) + +func ClearPetVMs() { + clear(petVMCache) +} + +func PrunePetVMs(petTypes ...string) { + if len(petTypes) == 0 { + return + } + for _, pt := range petTypes { + delete(petVMCache, pt) + } +} + +// TryPetScriptEvent fires a named event on the pet script (e.g. "onCommand", "PetAct"). +// userId is the owner of the pet. +func TryPetScriptEvent(eventName string, userId int) (bool, error) { + + user := users.GetByUserId(userId) + if user == nil { + return false, errors.New("user not found") + } + + if !user.Character.Pet.Exists() { + return false, errors.New("user has no pet") + } + + sPet := GetPet(&user.Character.Pet) + if sPet == nil { + return false, errors.New("pet not found") + } + + vmw, err := getPetVM(sPet) + if err != nil { + return false, err + } + + timestart := time.Now() + defer func() { + mudlog.Debug("TryPetScriptEvent()", "eventName", eventName, "petType", sPet.Type(), "time", time.Since(timestart)) + }() + + if onFunc, ok := vmw.GetFunction(eventName); ok { + + sActor := GetActor(userId, 0) + sRoom := GetRoom(sActor.GetRoomId()) + + userTextWrap.Set(`script-text`, ``, ``) + roomTextWrap.Set(`script-text`, ``, ``) + + tmr := time.AfterFunc(scriptPetTimeout, func() { + vmw.VM.Interrupt(errTimeout) + }) + res, err := onFunc(goja.Undefined(), + vmw.VM.ToValue(sPet), + vmw.VM.ToValue(sActor), + vmw.VM.ToValue(sRoom), + ) + vmw.VM.ClearInterrupt() + tmr.Stop() + + userTextWrap.Reset() + roomTextWrap.Reset() + + if err != nil { + finalErr := fmt.Errorf("%s(): %w", eventName, err) + if _, ok := finalErr.(*goja.Exception); ok { + mudlog.Error("JSVM", "exception", finalErr) + return false, finalErr + } else if errors.Is(finalErr, errTimeout) { + mudlog.Error("JSVM", "interrupted", finalErr) + return false, finalErr + } + mudlog.Error("JSVM", "error", finalErr) + return false, finalErr + } + + if boolVal, ok := res.Export().(bool); ok { + return boolVal, nil + } + } + + return false, ErrEventNotFound +} + +// TryPetCommand checks whether the pet script intercepts a command typed by its owner. +func TryPetCommand(cmd string, rest string, userId int) (bool, error) { + + user := users.GetByUserId(userId) + if user == nil { + return false, errors.New("user not found") + } + + if !user.Character.Pet.Exists() { + return false, ErrEventNotFound + } + + sPet := GetPet(&user.Character.Pet) + if sPet == nil { + return false, ErrEventNotFound + } + + vmw, err := getPetVM(sPet) + if err != nil { + return false, err + } + + timestart := time.Now() + defer func() { + mudlog.Debug("TryPetCommand()", "cmd", cmd, "petType", sPet.Type(), "userId", userId, "time", time.Since(timestart)) + }() + + sActor := GetActor(userId, 0) + sRoom := GetRoom(sActor.GetRoomId()) + + if onCommandFunc, ok := vmw.GetFunction(`onCommand_` + cmd); ok { + + userTextWrap.Set(`script-text`, ``, ``) + roomTextWrap.Set(`script-text`, ``, ``) + + tmr := time.AfterFunc(scriptPetTimeout, func() { + vmw.VM.Interrupt(errTimeout) + }) + res, err := onCommandFunc(goja.Undefined(), + vmw.VM.ToValue(rest), + vmw.VM.ToValue(sPet), + vmw.VM.ToValue(sActor), + vmw.VM.ToValue(sRoom), + ) + vmw.VM.ClearInterrupt() + tmr.Stop() + + userTextWrap.Reset() + roomTextWrap.Reset() + + if err != nil { + finalErr := fmt.Errorf("onCommand_%s(): %w", cmd, err) + if _, ok := finalErr.(*goja.Exception); ok { + mudlog.Error("JSVM", "exception", finalErr) + return false, finalErr + } else if errors.Is(finalErr, errTimeout) { + mudlog.Error("JSVM", "interrupted", finalErr) + return false, finalErr + } + mudlog.Error("JSVM", "error", finalErr) + return false, finalErr + } + + if boolVal, ok := res.Export().(bool); ok { + return boolVal, nil + } + + } else if onCommandFunc, ok := vmw.GetFunction(`onCommand`); ok { + + userTextWrap.Set(`script-text`, ``, ``) + roomTextWrap.Set(`script-text`, ``, ``) + + tmr := time.AfterFunc(scriptPetTimeout, func() { + vmw.VM.Interrupt(errTimeout) + }) + res, err := onCommandFunc(goja.Undefined(), + vmw.VM.ToValue(cmd), + vmw.VM.ToValue(rest), + vmw.VM.ToValue(sPet), + vmw.VM.ToValue(sActor), + vmw.VM.ToValue(sRoom), + ) + vmw.VM.ClearInterrupt() + tmr.Stop() + + userTextWrap.Reset() + roomTextWrap.Reset() + + if err != nil { + finalErr := fmt.Errorf("onCommand(): %w", err) + if _, ok := finalErr.(*goja.Exception); ok { + mudlog.Error("JSVM", "exception", finalErr) + return false, finalErr + } else if errors.Is(finalErr, errTimeout) { + mudlog.Error("JSVM", "interrupted", finalErr) + return false, finalErr + } + mudlog.Error("JSVM", "error", finalErr) + return false, finalErr + } + + if boolVal, ok := res.Export().(bool); ok { + return boolVal, nil + } + } + + return false, ErrEventNotFound +} + +func getPetVM(sPet *ScriptPet) (*VMWrapper, error) { + + scriptId := sPet.petRecord.Type + + if vm, ok := petVMCache[scriptId]; ok { + if vm == nil { + return nil, errNoScript + } + return vm, nil + } + + script := sPet.getScript() + if len(script) == 0 { + petVMCache[scriptId] = nil + return nil, errNoScript + } + + vm := goja.New() + setAllScriptingFunctions(vm) + + prg, err := goja.Compile(fmt.Sprintf(`pet-%s`, scriptId), script, false) + if err != nil { + finalErr := fmt.Errorf("Compile: %w", err) + return nil, finalErr + } + + tmr := time.AfterFunc(scriptLoadTimeout, func() { + vm.Interrupt(errTimeout) + }) + if _, err = vm.RunProgram(prg); err != nil { + finalErr := fmt.Errorf("RunProgram: %w", err) + if _, ok := finalErr.(*goja.Exception); ok { + mudlog.Error("JSVM", "exception", finalErr) + return nil, finalErr + } else if errors.Is(finalErr, errTimeout) { + mudlog.Error("JSVM", "interrupted", finalErr) + return nil, finalErr + } + mudlog.Error("JSVM", "error", finalErr) + return nil, finalErr + } + vm.ClearInterrupt() + tmr.Stop() + + vmw := newVMWrapper(vm, 0) + + petVMCache[scriptId] = vmw + + return vmw, nil +} + +// InvalidatePetVM removes the cached VM for a given pet type so the next call +// reloads the script from disk. Called after SavePetScript. +func InvalidatePetVM(petType string) { + delete(petVMCache, petType) +} + +// GetPetSpec returns the definition for a pet type by name (for scripting helpers). +func GetPetSpec(petType string) *pets.Pet { + cp := pets.GetPetCopy(petType) + if !cp.Exists() { + return nil + } + return &cp +} diff --git a/internal/scripting/pet_func.go b/internal/scripting/pet_func.go new file mode 100644 index 000000000..ea0816dba --- /dev/null +++ b/internal/scripting/pet_func.go @@ -0,0 +1,151 @@ +package scripting + +import ( + "github.com/GoMudEngine/GoMud/internal/pets" + "github.com/dop251/goja" +) + +func setPetFunctions(vm *goja.Runtime) { +} + +// ScriptPet wraps a live pet record and exposes it to the scripting engine. +// The underlying record is a pointer into the owner's character data, so +// mutations are reflected immediately without any extra save call. +type ScriptPet struct { + petRecord *pets.Pet +} + +// Type returns the pet's type identifier (e.g. "dog", "cat", "owl"). +func (p ScriptPet) Type() string { + if p.petRecord != nil { + return p.petRecord.Type + } + return `` +} + +// Name returns the pet's display name, including ANSI colour tags and any +// hunger indicator. Use NameSimple() when you only need the plain text name. +func (p ScriptPet) Name() string { + if p.petRecord != nil { + return p.petRecord.DisplayName() + } + return `` +} + +// NameSimple returns the plain text name of the pet with no colour tags or +// hunger indicator. Falls back to the type identifier if no name has been set. +func (p ScriptPet) NameSimple() string { + if p.petRecord == nil { + return `` + } + if p.petRecord.Name != `` { + return p.petRecord.Name + } + return p.petRecord.Type +} + +// SetName renames the pet. Pass an empty string to clear a custom name and +// revert to the type identifier. +func (p ScriptPet) SetName(name string) { + if p.petRecord != nil { + p.petRecord.Name = name + } +} + +// Level returns the pet's current level (1–10). +func (p ScriptPet) Level() int { + if p.petRecord != nil { + return p.petRecord.Level + } + return 0 +} + +// Food returns the pet's current hunger level as a string: +// "Starving" (0), "Hungry" (1), "Satisfied" (2), or "Full" (3). +func (p ScriptPet) Food() string { + if p.petRecord != nil { + return p.petRecord.Food.String() + } + return `` +} + +// FoodLevel returns the pet's raw hunger value: 0 (starving) through 3 (full). +func (p ScriptPet) FoodLevel() int { + if p.petRecord != nil { + return int(p.petRecord.Food) + } + return 0 +} + +// Feed increases the pet's hunger level by one step, up to a maximum of 3 +// (Full). Has no effect if the pet is already full. +func (p ScriptPet) Feed() { + if p.petRecord != nil { + p.petRecord.Food.Add() + } +} + +// Starve decreases the pet's hunger level by one step, down to a minimum of 0 +// (Starving). Has no effect if the pet is already starving. +func (p ScriptPet) Starve() { + if p.petRecord != nil { + p.petRecord.Food.Remove() + } +} + +// GetStatMod returns the effective stat modifier the pet currently grants its +// owner for the named stat (e.g. "strength", "speed", "smarts"). Returns 0 if +// the pet has no modifier for that stat at its current level. +func (p ScriptPet) GetStatMod(statName string) int { + if p.petRecord != nil { + return p.petRecord.StatMod(statName) + } + return 0 +} + +// GetCapacity returns the number of items the pet can carry at its current +// level. Returns 0 if the pet type has no carry ability. +func (p ScriptPet) GetCapacity() int { + if p.petRecord != nil { + return p.petRecord.GetEffectiveCapacity() + } + return 0 +} + +// ItemCount returns the number of items the pet is currently carrying. +func (p ScriptPet) ItemCount() int { + if p.petRecord != nil { + return len(p.petRecord.Items) + } + return 0 +} + +// HasScript returns true if this pet type has a script file on disk. +func (p ScriptPet) HasScript() bool { + if p.petRecord != nil { + return p.petRecord.HasScript() + } + return false +} + +// //////////////////////////////////////////////////////// +// +// Package-internal helpers +// +// //////////////////////////////////////////////////////// + +func (p ScriptPet) getScript() string { + if p.petRecord != nil { + return p.petRecord.GetScript() + } + return `` +} + +// GetPet returns a ScriptPet wrapping the given pet pointer. +// Returns nil if pet is nil or does not exist. +func GetPet(pet *pets.Pet) *ScriptPet { + if pet == nil || !pet.Exists() { + return nil + } + return &ScriptPet{petRecord: pet} +} diff --git a/internal/scripting/schema.go b/internal/scripting/schema.go index 61f6282c7..29680873a 100644 --- a/internal/scripting/schema.go +++ b/internal/scripting/schema.go @@ -54,6 +54,7 @@ func GetScriptFunctionsSchema() *ScriptFunctionsSchema { "room": roomScriptType(), "mob": mobScriptType(), "item": itemScriptType(), + "pet": petScriptType(), "spell": spellScriptType(), "buff": buffScriptType(), }, @@ -388,6 +389,52 @@ func spellScriptType() *ScriptTypeDef { } } +func petScriptType() *ScriptTypeDef { + return &ScriptTypeDef{ + Label: "Pet Script", + Description: "Scripts attached to pet types. Triggered by owner commands and round-based pet actions.", + Functions: []ScriptFuncDef{ + { + Name: "PetAct", + Description: "Called each round when the pet's owner is in a room. Use this to produce scripted pet behavior such as emotes, messages, or reactions.", + Params: []ScriptFuncParam{ + {Name: "pet", Type: "PetObject", Description: "The pet."}, + {Name: "actor", Type: "ActorObject", Description: "The owner of the pet."}, + {Name: "room", Type: "RoomObject", Description: "The room the pet and owner are in."}, + }, + ReturnSemantics: "Return value is ignored.", + Stub: "function PetAct(pet, actor, room) {\n\n}\n", + }, + { + Name: "onCommand", + Description: "Called when any command is typed by the pet's owner.", + Params: []ScriptFuncParam{ + {Name: "cmd", Type: "string", Description: "The command word typed by the owner."}, + {Name: "rest", Type: "string", Description: "Everything entered after the command word."}, + {Name: "pet", Type: "PetObject", Description: "The pet."}, + {Name: "actor", Type: "ActorObject", Description: "The owner of the pet."}, + {Name: "room", Type: "RoomObject", Description: "The current room."}, + }, + ReturnSemantics: "Return true to halt further command processing.", + Stub: "function onCommand(cmd, rest, pet, actor, room) {\n\n return false;\n}\n", + }, + { + Name: "onCommand_{command}", + Description: "Called when a specific command is typed by the pet's owner. If defined, the generic onCommand() will not fire for this command.", + Params: []ScriptFuncParam{ + {Name: "rest", Type: "string", Description: "Everything entered after the command word."}, + {Name: "pet", Type: "PetObject", Description: "The pet."}, + {Name: "actor", Type: "ActorObject", Description: "The owner of the pet."}, + {Name: "room", Type: "RoomObject", Description: "The current room."}, + }, + ReturnSemantics: "Return true to halt further command processing.", + Dynamic: commandDynamic, + Stub: "function onCommand_{command}(rest, pet, actor, room) {\n\n return true;\n}\n", + }, + }, + } +} + func buffScriptType() *ScriptTypeDef { return &ScriptTypeDef{ Label: "Buff Script", diff --git a/internal/scripting/scripting.go b/internal/scripting/scripting.go index 6592a32ff..e5ca86c73 100644 --- a/internal/scripting/scripting.go +++ b/internal/scripting/scripting.go @@ -90,6 +90,7 @@ func Setup(scriptLoadTimeoutMs int, scriptRoomTimeoutMs int) { scriptBuffTimeout = t scriptItemTimeout = t scriptMobTimeout = t + scriptPetTimeout = t scriptSpellTimeout = t } @@ -99,6 +100,7 @@ func setAllScriptingFunctions(vm *goja.Runtime) { setActorFunctions(vm) setSpellFunctions(vm) setItemFunctions(vm) + setPetFunctions(vm) setUtilFunctions(vm) setModuleFunctions(vm) setPanelFunctions(vm) @@ -141,6 +143,7 @@ func PruneVMs(forceClear ...bool) { ClearMobVMs() ClearBuffVMs() ClearItemVMs() + ClearPetVMs() ClearSpellVMs() } else { PruneRoomVMs() diff --git a/internal/usercommands/usercommands.go b/internal/usercommands/usercommands.go index 337ef972a..f1973b331 100644 --- a/internal/usercommands/usercommands.go +++ b/internal/usercommands/usercommands.go @@ -336,6 +336,13 @@ func TryCommand(cmd string, rest string, userId int, flags events.EventFlag) (bo } } + // Check if the pet script intercepts this command + if user.Character.Pet.Exists() { + if handled, err := scripting.TryPetCommand(cmd, rest, user.UserId); err == nil && handled { + return true, nil + } + } + // Check if the "rest" is an item the character has matchingItem, found := user.Character.FindInBackpack(rest) if !found { diff --git a/internal/web/api_routes.go b/internal/web/api_routes.go index 26fca7c2c..1c5ec5c1f 100644 --- a/internal/web/api_routes.go +++ b/internal/web/api_routes.go @@ -141,10 +141,12 @@ func registerAdminAPIRoutes(mux *http.ServeMux) { mux.HandleFunc("PATCH /admin/api/v1/races/{raceId}", doBasicAuth(RunWithMUDLocked(apiV1PatchRace))) mux.HandleFunc("DELETE /admin/api/v1/races/{raceId}", doBasicAuth(RunWithMUDLocked(apiV1DeleteRace))) - // Pets + // Pets - script sub-route before wildcard {petname} mux.HandleFunc("GET /admin/api/v1/pets/ranks", doBasicAuth(RunWithMUDLocked(apiV1GetPetRanks))) mux.HandleFunc("GET /admin/api/v1/pets", doBasicAuth(RunWithMUDLocked(apiV1GetPets))) mux.HandleFunc("POST /admin/api/v1/pets", doBasicAuth(RunWithMUDLocked(apiV1CreatePet))) + mux.HandleFunc("GET /admin/api/v1/pets/{petname}/script", doBasicAuth(RunWithMUDLocked(apiV1GetPetScript))) + mux.HandleFunc("PUT /admin/api/v1/pets/{petname}/script", doBasicAuth(RunWithMUDLocked(apiV1PutPetScript))) mux.HandleFunc("PATCH /admin/api/v1/pets/{petname}", doBasicAuth(RunWithMUDLocked(apiV1PatchPet))) mux.HandleFunc("DELETE /admin/api/v1/pets/{petname}", doBasicAuth(RunWithMUDLocked(apiV1DeletePet))) diff --git a/internal/web/api_v1_pets.go b/internal/web/api_v1_pets.go index dfa0a2463..e497b7a6c 100644 --- a/internal/web/api_v1_pets.go +++ b/internal/web/api_v1_pets.go @@ -7,6 +7,7 @@ import ( "github.com/GoMudEngine/GoMud/internal/combat" "github.com/GoMudEngine/GoMud/internal/pets" + "github.com/GoMudEngine/GoMud/internal/scripting" ) // GET /admin/api/v1/pets @@ -99,3 +100,51 @@ func apiV1DeletePet(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, APIResponse[struct{}]{Success: true}) } + +// GET /admin/api/v1/pets/{petname}/script +func apiV1GetPetScript(w http.ResponseWriter, r *http.Request) { + petName := strings.ToLower(strings.TrimSpace(r.PathValue("petname"))) + if petName == "" { + writeAPIError(w, http.StatusBadRequest, "petname is required") + return + } + + all := pets.GetAllPetSpecs() + spec, ok := all[petName] + if !ok { + writeAPIError(w, http.StatusNotFound, "pet type not found: "+petName) + return + } + + writeJSON(w, http.StatusOK, APIResponse[map[string]string]{ + Success: true, + Data: map[string]string{"script": spec.GetScript()}, + }) +} + +// PUT /admin/api/v1/pets/{petname}/script +func apiV1PutPetScript(w http.ResponseWriter, r *http.Request) { + petName := strings.ToLower(strings.TrimSpace(r.PathValue("petname"))) + if petName == "" { + writeAPIError(w, http.StatusBadRequest, "petname is required") + 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 := pets.SavePetScript(petName, body.Script); err != nil { + writeAPIError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Invalidate the cached VM so the next invocation picks up the new script + scripting.InvalidatePetVM(petName) + + writeJSON(w, http.StatusOK, APIResponse[struct{}]{Success: true}) +}