Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 88 additions & 2 deletions _datafiles/html/admin/docs-modules.html
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ <h3>Modules Docs</h3>
<a href="#api-commands">Commands</a>
<a href="#api-scripting">Scripting Bridge</a>
<a href="#api-filesystem">File System</a>
<a href="#api-datafiles">Content Data Files</a>
<a href="#api-persistence">Persistence</a>
<a href="#api-config">Configuration</a>
<a href="#api-roomtags">Room Tags</a>
Expand Down Expand Up @@ -763,14 +764,99 @@ <h3>Embedded Directory Conventions</h3>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="dir">templates/</span> <span class="note">-- Go templates used by templates.Process()</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="dir">html/admin/</span> <span class="note">-- admin page HTML fragments</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="dir">html/public/</span> <span class="note">-- public-facing web pages</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="dir">items/</span> <span class="note">-- item YAML definitions</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="dir">items/</span> <span class="note">-- item YAML definitions (see Content Data Files)</span><br>
&nbsp;&nbsp;<span class="dir">data-overlays/</span> <span class="note">-- config defaults (see Data Overlays)</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;config.yaml <span class="note">-- seeds Modules.&lt;name&gt;.* config keys</span>
</div>

<div class="callout callout-tip">
<svg class="callout-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<p>Embedded files override core data files when a path collision occurs, so a module can replace any template, help file, or data file by placing a file at the same relative path inside <code>files/datafiles/</code>.</p>
<p>Embedded files override core data files when a path collision occurs, so a module can replace any template, help file, or data file by placing a file at the same relative path inside <code>files/datafiles/</code>. For gameplay content (items, mobs, buffs, etc.) see <a href="#api-datafiles">Content Data Files</a> for the per-type folder structure and naming rules.</p>
</div>
</section>

<!-- ══════════════════════════════════════════════════════════════════
PLUGIN API — CONTENT DATA FILES
══════════════════════════════════════════════════════════════════ -->
<section class="doc-section" id="api-datafiles">
<h2>Plugin API: Content Data Files</h2>

<p>A module can ship gameplay content as embedded YAML that is merged into the world at load time, exactly like the bundled core data files. The supported content systems are <code>items</code>, <code>mutators</code>, <code>buffs</code>, <code>pets</code>, <code>quests</code>, <code>mobs</code>, and <code>conversations</code>. No registration call is required &mdash; simply place correctly-named YAML files under <code>files/datafiles/&lt;system&gt;/</code> and attach the file system with <code>plug.AttachFileSystem(files)</code>. The engine discovers and merges them automatically.</p>

<div class="callout callout-info">
<svg class="callout-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<p><strong>Disk wins on collision.</strong> If a server's on-disk <code>_datafiles/</code> already defines an entry with the same id/key as a module entry, the on-disk one is kept and the module's duplicate is logged and skipped. This lets operators override module content by dropping a file on disk.</p>
</div>

<h3>Required Folder Structure</h3>
<p>Each file's path inside the embedded file system must end with the value the engine derives from the spec (its <code>Filepath()</code>). The id in the filename must match the id inside the YAML, and the name segment must match the spec name run through filename sanitisation (lowercase; every character that is not <code>a&ndash;z</code> or <code>0&ndash;9</code> becomes an underscore; apostrophes are dropped). A mismatched filename is rejected.</p>

<div class="dir-tree">
<span class="dir">modules/mymod/files/datafiles/</span><br>
&nbsp;&nbsp;<span class="dir">items/</span> <span class="note">-- {itemId}-{name}.yaml (folder may be nested by item type)</span><br>
&nbsp;&nbsp;<span class="dir">mutators/</span> <span class="note">-- {mutatorId}.yaml</span><br>
&nbsp;&nbsp;<span class="dir">buffs/</span> <span class="note">-- {buffId}-{name}.yaml</span><br>
&nbsp;&nbsp;<span class="dir">pets/</span> <span class="note">-- {pettype}.yaml</span><br>
&nbsp;&nbsp;<span class="dir">quests/</span> <span class="note">-- {questId}-{name}.yaml</span><br>
&nbsp;&nbsp;<span class="dir">mobs/</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="dir">{zone}/</span> <span class="note">-- {mobId}-{charactername}.yaml</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="dir">scripts/</span> <span class="note">-- on-disk mob scripts (not used by embedded modules)</span><br>
&nbsp;&nbsp;<span class="dir">conversations/</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="dir">{zone}/</span> <span class="note">-- {mobId}.yaml</span>
</div>

<table class="ref-table">
<thead><tr><th>System</th><th>Folder &amp; filename</th><th>Key</th><th>Scripts</th></tr></thead>
<tbody>
<tr><td><code>items</code></td><td><code>items/{itemId}-{name}.yaml</code> (may be nested by item type, e.g. <code>items/armor-20000/head/</code>)</td><td>numeric item id</td><td>Yes &mdash; <code>items.RegisterItemScript</code></td></tr>
<tr><td><code>mutators</code></td><td><code>mutators/{mutatorId}.yaml</code></td><td>string mutator id</td><td>No</td></tr>
<tr><td><code>buffs</code></td><td><code>buffs/{buffId}-{name}.yaml</code></td><td>numeric buff id</td><td>Yes &mdash; <code>buffs.RegisterBuffScript</code></td></tr>
<tr><td><code>pets</code></td><td><code>pets/{pettype}.yaml</code></td><td>pet type string</td><td>Yes &mdash; <code>pets.RegisterPetScript</code></td></tr>
<tr><td><code>quests</code></td><td><code>quests/{questId}-{name}.yaml</code></td><td>numeric quest id</td><td>No</td></tr>
<tr><td><code>mobs</code></td><td><code>mobs/{zone}/{mobId}-{charactername}.yaml</code></td><td>numeric mob id</td><td>Yes &mdash; <code>mobs.RegisterMobScript</code></td></tr>
<tr><td><code>conversations</code></td><td><code>conversations/{zone}/{mobId}.yaml</code></td><td>(zone, mob id) &mdash; looked up on demand</td><td>No</td></tr>
</tbody>
</table>

<div class="callout callout-warn">
<svg class="callout-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<p>Zone names in <code>mobs/</code> and <code>conversations/</code> paths must be sanitised (spaces become underscores, all lowercase) and must match the <code>zone</code> field in the mob/conversation data. For mobs, the <code>{mobId}</code> and <code>{charactername}</code> in the filename must match the <code>mobid</code> and <code>character.name</code> fields, or the file is rejected.</p>
</div>

<h3>Embedded Scripts</h3>
<p>For systems that support JavaScript (<code>items</code>, <code>buffs</code>, <code>pets</code>, <code>mobs</code>), scripts placed on disk are loaded by file path. Embedded module scripts are not on disk, so register them in <code>init()</code> with the matching helper. <code>GetScript()</code> checks these registries before falling back to a disk path.</p>

<div class="code-block">
<div class="code-block-header">Registering embedded content scripts</div>
<pre><code>import (
"github.com/GoMudEngine/GoMud/internal/buffs"
"github.com/GoMudEngine/GoMud/internal/items"
"github.com/GoMudEngine/GoMud/internal/mobs"
"github.com/GoMudEngine/GoMud/internal/pets"
)

func init() {
plug := plugins.New("mymod", "1.0")
if err := plug.AttachFileSystem(files); err != nil {
panic(err)
}

// Item / buff scripts are keyed by numeric id.
items.RegisterItemScript(40050, itemScriptSrc)
buffs.RegisterBuffScript(9001, buffScriptSrc)

// Pet scripts are keyed by pet type string.
pets.RegisterPetScript("plasmahound", petScriptSrc)

// Mob scripts are keyed by (mobId, scriptTag). Empty tag = base script.
mobs.RegisterMobScript(9001, "", mobScriptSrc)
mobs.RegisterMobScript(9001, "combat", mobCombatScriptSrc)
}</code></pre>
</div>

<div class="callout callout-info">
<svg class="callout-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<p><strong>Limitation:</strong> embedded mob scripts are not enumerated by the admin script-tag editor, so module-provided mob scripts cannot be edited from the admin UI (the same constraint applies to embedded item scripts). <code>buffs</code> must be present before <code>items</code> at load time for item value calculation; the engine already merges plugin buffs early to preserve this ordering.</p>
</div>
</section>

Expand Down
1 change: 1 addition & 0 deletions internal/buffs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- `buffs.go`: runtime buff state, collection indexing, trigger flow, pruning, and stat aggregation.
- `flags.go`: shared flag constants used by other systems.
- `admin.go`: CRUD helpers that persist buff YAML and refresh in-memory state.
- `plugin.go`: module data-file integration. `RegisterFS(...)` registers plugin filesystems; `loadPluginBuffs` merges embedded `buffs/*.yaml` into the spec map inside `LoadDataFiles` (after disk load, before items load). `RegisterBuffScript(buffId, src)` registers embedded JS; `BuffSpec.GetScript()` checks it before the disk path. Disk buffs win on duplicate ids.

## Working Rules

Expand Down
9 changes: 9 additions & 0 deletions internal/buffs/buffspec.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ func (b *BuffSpec) Filepath() string {

func (b *BuffSpec) GetScript() string {

// Check plugin-registered scripts first.
if script := getPluginScript(b.BuffId); script != `` {
return script
}

scriptPath := b.GetScriptPath()
// Load the script into a string
if _, err := os.Stat(scriptPath); err == nil {
Expand Down Expand Up @@ -192,5 +197,9 @@ func LoadDataFiles() {

buffs = tmpBuffs

// Merge buffs from plugin file systems. Must happen here (not deferred)
// so plugin buffs are present before items load and compute their values.
loadPluginBuffs(buffs)

mudlog.Info("buffSpec.LoadDataFiles()", "loadedCount", len(buffs), "Time Taken", time.Since(start))
}
102 changes: 102 additions & 0 deletions internal/buffs/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package buffs

import (
"io/fs"
"strings"

"github.com/GoMudEngine/GoMud/internal/fileloader"
"github.com/GoMudEngine/GoMud/internal/mudlog"
"gopkg.in/yaml.v2"
)

var (
pluginFileSystems []fileloader.ReadableGroupFS
pluginScripts = map[int]string{} // buffId -> JS source
)

// RegisterFS registers a plugin file system to be searched when loading buff
// data files. Must be called before LoadDataFiles().
func RegisterFS(f ...fileloader.ReadableGroupFS) {
pluginFileSystems = append(pluginFileSystems, f...)
}

// RegisterBuffScript registers an embedded JS script for a given buff ID.
// This is used by modules that embed their scripts rather than placing them
// on disk alongside the YAML definition.
func RegisterBuffScript(buffId int, script string) {
if buffId < 0 {
buffId *= -1
}
pluginScripts[buffId] = script
}

// getPluginScript returns the registered plugin script for buffId, or "".
func getPluginScript(buffId int) string {
if buffId < 0 {
buffId *= -1
}
return pluginScripts[buffId]
}

// loadPluginBuffs walks every sub-filesystem of every registered plugin FS,
// reading buff YAML files from a "buffs/" prefix and merging them into dst.
// Disk-loaded buffs take precedence: a plugin buff with a duplicate id is
// logged and skipped.
func loadPluginBuffs(dst map[int]*BuffSpec) {
for _, groupFS := range pluginFileSystems {
for subFS := range groupFS.AllFileSubSystems {
loadBuffsFromFS(subFS, dst)
}
}
}

func loadBuffsFromFS(subFS fs.ReadFileFS, dst map[int]*BuffSpec) {
if pl, ok := subFS.(fileloader.PathLister); ok {
for _, path := range pl.KnownPaths() {
if !strings.HasPrefix(path, `buffs/`) || !strings.HasSuffix(path, `.yaml`) {
continue
}
loadBuffFileFromFS(subFS, path, dst)
}
return
}

_ = fs.WalkDir(subFS, `buffs`, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, `.yaml`) {
return nil
}
loadBuffFileFromFS(subFS, path, dst)
return nil
})
}

func loadBuffFileFromFS(subFS fs.ReadFileFS, path string, dst map[int]*BuffSpec) {
b, err := subFS.ReadFile(path)
if err != nil {
mudlog.Error("buffs.loadBuffsFromFS", "path", path, "error", err)
return
}

var spec BuffSpec
if err := yaml.Unmarshal(b, &spec); err != nil {
mudlog.Error("buffs.loadBuffsFromFS", "path", path, "error", err)
return
}

if !strings.HasSuffix(path, spec.Filepath()) {
mudlog.Error("buffs.loadBuffsFromFS", "path", path, "expected suffix", spec.Filepath(), "error", "filepath mismatch")
return
}

if err := spec.Validate(); err != nil {
mudlog.Error("buffs.loadBuffsFromFS", "path", path, "error", err)
return
}

if _, exists := dst[spec.BuffId]; exists {
mudlog.Error("buffs.loadBuffsFromFS", "buffId", spec.BuffId, "path", path, "error", "duplicate buff id")
return
}

dst[spec.BuffId] = &spec
}
152 changes: 152 additions & 0 deletions internal/buffs/plugin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package buffs

import (
"io/fs"
"os"
"testing"

"github.com/GoMudEngine/GoMud/internal/mudlog"
)

func TestMain(m *testing.M) {
mudlog.SetupLogger(nil, "", "", false)
os.Exit(m.Run())
}

type fakeFS struct {
files map[string][]byte
}

func newFakeFS(files map[string][]byte) *fakeFS {
return &fakeFS{files: files}
}

func (f *fakeFS) ReadFile(name string) ([]byte, error) {
if b, ok := f.files[name]; ok {
return b, nil
}
return nil, fs.ErrNotExist
}

func (f *fakeFS) Open(name string) (fs.File, error) { return nil, fs.ErrNotExist }

func (f *fakeFS) KnownPaths() []string {
paths := make([]string, 0, len(f.files))
for p := range f.files {
paths = append(paths, p)
}
return paths
}

func (f *fakeFS) AllFileSubSystems(yield func(fs.ReadFileFS) bool) { yield(f) }

func resetPluginState() {
pluginFileSystems = nil
pluginScripts = map[int]string{}
}

func TestLoadPluginBuffs_MergesValidBuff(t *testing.T) {
resetPluginState()
defer resetPluginState()

yamlData := []byte("buffid: 9001\nname: Plugin Buff\ntriggerrate: 1 round\ntriggercount: 3\n")
RegisterFS(newFakeFS(map[string][]byte{
`buffs/9001-plugin_buff.yaml`: yamlData,
}))

dst := map[int]*BuffSpec{}
loadPluginBuffs(dst)

b, ok := dst[9001]
if !ok {
t.Fatalf("expected buff 9001 to be loaded")
}
if b.Name != "Plugin Buff" {
t.Fatalf("expected name %q, got %q", "Plugin Buff", b.Name)
}
}

func TestLoadPluginBuffs_IgnoresWrongPrefix(t *testing.T) {
resetPluginState()
defer resetPluginState()

RegisterFS(newFakeFS(map[string][]byte{
`items/9002-x.yaml`: []byte("buffid: 9002\nname: X\ntriggerrate: 1 round\ntriggercount: 1\n"),
}))

dst := map[int]*BuffSpec{}
loadPluginBuffs(dst)

if len(dst) != 0 {
t.Fatalf("expected wrong-prefix buff to be ignored, got %d", len(dst))
}
}

func TestLoadPluginBuffs_RejectsFilepathMismatch(t *testing.T) {
resetPluginState()
defer resetPluginState()

RegisterFS(newFakeFS(map[string][]byte{
`buffs/wrong.yaml`: []byte("buffid: 9003\nname: Good Buff\ntriggerrate: 1 round\ntriggercount: 1\n"),
}))

dst := map[int]*BuffSpec{}
loadPluginBuffs(dst)

if len(dst) != 0 {
t.Fatalf("expected mismatched-filepath buff to be skipped, got %d", len(dst))
}
}

func TestLoadPluginBuffs_SkipsInvalid(t *testing.T) {
resetPluginState()
defer resetPluginState()

// triggercount of 0 fails Validate().
RegisterFS(newFakeFS(map[string][]byte{
`buffs/9004-bad.yaml`: []byte("buffid: 9004\nname: Bad\ntriggerrate: 1 round\ntriggercount: 0\n"),
}))

dst := map[int]*BuffSpec{}
loadPluginBuffs(dst)

if len(dst) != 0 {
t.Fatalf("expected invalid buff to be skipped, got %d", len(dst))
}
}

func TestLoadPluginBuffs_DiskWinsOnDuplicate(t *testing.T) {
resetPluginState()
defer resetPluginState()

RegisterFS(newFakeFS(map[string][]byte{
`buffs/9005-plugin.yaml`: []byte("buffid: 9005\nname: Plugin\ntriggerrate: 1 round\ntriggercount: 1\n"),
}))

dst := map[int]*BuffSpec{
9005: {BuffId: 9005, Name: "Disk"},
}
loadPluginBuffs(dst)

if dst[9005].Name != "Disk" {
t.Fatalf("expected disk buff to win, got %q", dst[9005].Name)
}
}

func TestRegisterBuffScript_ReturnedByGetScript(t *testing.T) {
resetPluginState()
defer resetPluginState()

RegisterBuffScript(9006, "onApply()")

spec := &BuffSpec{BuffId: 9006, Name: "Scripted"}
if got := spec.GetScript(); got != "onApply()" {
t.Fatalf("expected plugin script, got %q", got)
}

// Negative ids resolve to the same registered script.
spec.BuffId = -9006
if got := spec.GetScript(); got != "onApply()" {
t.Fatalf("expected plugin script for negative id, got %q", got)
}
}
Loading