templates/-- Go templates used by templates.Process() html/admin/-- admin page HTML fragments html/public/-- public-facing web pages
- items/-- item YAML definitions
+ items/-- item YAML definitions (see Content Data Files) data-overlays/-- config defaults (see Data Overlays)
config.yaml -- seeds Modules.<name>.* config keys
-
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 files/datafiles/.
+
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 files/datafiles/. For gameplay content (items, mobs, buffs, etc.) see Content Data Files for the per-type folder structure and naming rules.
+
+
+
+
+
+
Plugin API: Content Data Files
+
+
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 items, mutators, buffs, pets, quests, mobs, and conversations. No registration call is required — simply place correctly-named YAML files under files/datafiles/<system>/ and attach the file system with plug.AttachFileSystem(files). The engine discovers and merges them automatically.
+
+
+
+
Disk wins on collision. If a server's on-disk _datafiles/ 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.
+
+
+
Required Folder Structure
+
Each file's path inside the embedded file system must end with the value the engine derives from the spec (its Filepath()). 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 a–z or 0–9 becomes an underscore; apostrophes are dropped). A mismatched filename is rejected.
+
+
+modules/mymod/files/datafiles/
+ items/-- {itemId}-{name}.yaml (folder may be nested by item type)
+ mutators/-- {mutatorId}.yaml
+ buffs/-- {buffId}-{name}.yaml
+ pets/-- {pettype}.yaml
+ quests/-- {questId}-{name}.yaml
+ mobs/
+ {zone}/-- {mobId}-{charactername}.yaml
+ scripts/-- on-disk mob scripts (not used by embedded modules)
+ conversations/
+ {zone}/-- {mobId}.yaml
+
+
+
+
System
Folder & filename
Key
Scripts
+
+
items
items/{itemId}-{name}.yaml (may be nested by item type, e.g. items/armor-20000/head/)
numeric item id
Yes — items.RegisterItemScript
+
mutators
mutators/{mutatorId}.yaml
string mutator id
No
+
buffs
buffs/{buffId}-{name}.yaml
numeric buff id
Yes — buffs.RegisterBuffScript
+
pets
pets/{pettype}.yaml
pet type string
Yes — pets.RegisterPetScript
+
quests
quests/{questId}-{name}.yaml
numeric quest id
No
+
mobs
mobs/{zone}/{mobId}-{charactername}.yaml
numeric mob id
Yes — mobs.RegisterMobScript
+
conversations
conversations/{zone}/{mobId}.yaml
(zone, mob id) — looked up on demand
No
+
+
+
+
+
+
Zone names in mobs/ and conversations/ paths must be sanitised (spaces become underscores, all lowercase) and must match the zone field in the mob/conversation data. For mobs, the {mobId} and {charactername} in the filename must match the mobid and character.name fields, or the file is rejected.
+
+
+
Embedded Scripts
+
For systems that support JavaScript (items, buffs, pets, mobs), scripts placed on disk are loaded by file path. Embedded module scripts are not on disk, so register them in init() with the matching helper. GetScript() checks these registries before falling back to a disk path.
+
+
+
Registering embedded content scripts
+
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)
+}
+
+
+
+
+
Limitation: 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). buffs must be present before items at load time for item value calculation; the engine already merges plugin buffs early to preserve this ordering.
diff --git a/internal/buffs/AGENTS.md b/internal/buffs/AGENTS.md
index ca3806329..cc092e981 100644
--- a/internal/buffs/AGENTS.md
+++ b/internal/buffs/AGENTS.md
@@ -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
diff --git a/internal/buffs/buffspec.go b/internal/buffs/buffspec.go
index 7323f9330..cd3a20afb 100644
--- a/internal/buffs/buffspec.go
+++ b/internal/buffs/buffspec.go
@@ -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 {
@@ -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))
}
diff --git a/internal/buffs/plugin.go b/internal/buffs/plugin.go
new file mode 100644
index 000000000..d23934135
--- /dev/null
+++ b/internal/buffs/plugin.go
@@ -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
+}
diff --git a/internal/buffs/plugin_test.go b/internal/buffs/plugin_test.go
new file mode 100644
index 000000000..cf65758b2
--- /dev/null
+++ b/internal/buffs/plugin_test.go
@@ -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)
+ }
+}
diff --git a/internal/conversations/conversations.go b/internal/conversations/conversations.go
index 60fe56069..a7e8e7a84 100644
--- a/internal/conversations/conversations.go
+++ b/internal/conversations/conversations.go
@@ -32,20 +32,24 @@ func AttemptConversation(initiatorMobId int, initatorInstanceId int, initiatorNa
filePath := util.FilePath(convFolder + `/` + fileName)
- _, err := os.Stat(filePath)
- if err != nil {
- return 0
- }
+ var bytes []byte
- bytes, err := util.ReadFile(filePath)
- if err != nil {
- mudlog.Error("AttemptConversation()", "error", "Problem reading conversation datafile "+filePath+": "+err.Error())
+ if _, err := os.Stat(filePath); err == nil {
+ bytes, err = util.ReadFile(filePath)
+ if err != nil {
+ mudlog.Error("AttemptConversation()", "error", "Problem reading conversation datafile "+filePath+": "+err.Error())
+ return 0
+ }
+ } else if pluginBytes, ok := readPluginConversationFile(zone, initiatorMobId); ok {
+ // Fall back to a plugin-provided conversation file (disk takes priority).
+ bytes = pluginBytes
+ } else {
return 0
}
var dataFile []ConversationData
- err = yaml.Unmarshal(bytes, &dataFile)
+ err := yaml.Unmarshal(bytes, &dataFile)
if err != nil {
mudlog.Error("AttemptConversation()", "error", "Problem unmarshalling conversation datafile "+filePath+": "+err.Error())
return 0
@@ -158,9 +162,7 @@ func HasConverseFile(mobId int, zone string) bool {
cacheKey := strconv.Itoa(mobId) + `-` + zone
if result, ok := converseCheckCache[cacheKey]; ok {
- if result == false {
- return false
- }
+ return result
}
convFolder := string(configs.GetFilePathsConfig().DataFiles) + `/conversations`
@@ -169,15 +171,16 @@ func HasConverseFile(mobId int, zone string) bool {
filePath := util.FilePath(convFolder + `/` + fileName)
- if _, err := os.Stat(filePath); err != nil {
- converseCheckCache[cacheKey] = false
- return false
+ exists := false
+ if _, err := os.Stat(filePath); err == nil {
+ exists = true
+ } else if hasPluginConversationFile(zone, mobId) {
+ exists = true
}
- converseCheckCache[cacheKey] = true
-
- return true
+ converseCheckCache[cacheKey] = exists
+ return exists
}
func (c *Conversation) NextActions(roundNow uint64) []string {
diff --git a/internal/conversations/plugin.go b/internal/conversations/plugin.go
new file mode 100644
index 000000000..2d0d26171
--- /dev/null
+++ b/internal/conversations/plugin.go
@@ -0,0 +1,45 @@
+package conversations
+
+import (
+ "fmt"
+
+ "github.com/GoMudEngine/GoMud/internal/fileloader"
+)
+
+var (
+ pluginFileSystems []fileloader.ReadableGroupFS
+)
+
+// RegisterFS registers a plugin file system to be searched when looking up
+// conversation data files. Must be called before conversations are requested.
+func RegisterFS(f ...fileloader.ReadableGroupFS) {
+ pluginFileSystems = append(pluginFileSystems, f...)
+}
+
+// pluginConversationKey builds the relative path used to look up a conversation
+// file inside a plugin file system. It matches the on-disk layout
+// (conversations//.yaml) minus the data-files root, which is the
+// same short-path form AttachFileSystem registers.
+func pluginConversationKey(zone string, mobId int) string {
+ return fmt.Sprintf(`conversations/%s/%d.yaml`, zone, mobId)
+}
+
+// readPluginConversationFile returns the bytes of a conversation file provided
+// by a registered plugin file system, or (nil, false) if none provide it.
+// zone is expected to already be sanitized via ZoneNameSanitize.
+func readPluginConversationFile(zone string, mobId int) ([]byte, bool) {
+ key := pluginConversationKey(zone, mobId)
+ for _, groupFS := range pluginFileSystems {
+ if b, err := groupFS.ReadFile(key); err == nil {
+ return b, true
+ }
+ }
+ return nil, false
+}
+
+// hasPluginConversationFile reports whether any registered plugin file system
+// provides a conversation file for the given sanitized zone and mob id.
+func hasPluginConversationFile(zone string, mobId int) bool {
+ _, ok := readPluginConversationFile(zone, mobId)
+ return ok
+}
diff --git a/internal/conversations/plugin_test.go b/internal/conversations/plugin_test.go
new file mode 100644
index 000000000..a74e1459f
--- /dev/null
+++ b/internal/conversations/plugin_test.go
@@ -0,0 +1,121 @@
+package conversations
+
+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
+ converseCheckCache = map[string]bool{}
+ conversations = map[int]*Conversation{}
+ conversationCounter = map[string]int{}
+ conversationUniqueId = 0
+}
+
+const sampleConversation = `-
+ Supported:
+ "*": ["*"]
+ Conversation:
+ - ["#1 say hello"]
+ - ["#2 say hi"]
+`
+
+func TestHasConverseFile_FindsPluginFile(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ RegisterFS(newFakeFS(map[string][]byte{
+ `conversations/testzone/9001.yaml`: []byte(sampleConversation),
+ }))
+
+ if !HasConverseFile(9001, "TestZone") {
+ t.Fatalf("expected plugin conversation file to be found")
+ }
+ // Second call exercises the cache path.
+ if !HasConverseFile(9001, "TestZone") {
+ t.Fatalf("expected cached plugin conversation file to be found")
+ }
+}
+
+func TestHasConverseFile_MissingReturnsFalse(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ if HasConverseFile(9002, "TestZone") {
+ t.Fatalf("expected missing conversation to return false")
+ }
+}
+
+func TestAttemptConversation_UsesPluginFile(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ RegisterFS(newFakeFS(map[string][]byte{
+ `conversations/testzone/9001.yaml`: []byte(sampleConversation),
+ }))
+
+ convId := AttemptConversation(9001, 1, "goblin", 2, "rat", "TestZone")
+ if convId == 0 {
+ t.Fatalf("expected a non-zero conversation id from plugin file")
+ }
+
+ c := getConversation(convId)
+ if c == nil {
+ t.Fatalf("expected conversation to be stored")
+ }
+ if len(c.ActionList) != 2 {
+ t.Fatalf("expected 2 conversation actions, got %d", len(c.ActionList))
+ }
+}
+
+func TestReadPluginConversationFile_KeyFormat(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ RegisterFS(newFakeFS(map[string][]byte{
+ `conversations/frostfang/42.yaml`: []byte(sampleConversation),
+ }))
+
+ if _, ok := readPluginConversationFile("frostfang", 42); !ok {
+ t.Fatalf("expected plugin conversation file at frostfang/42")
+ }
+ if _, ok := readPluginConversationFile("frostfang", 43); ok {
+ t.Fatalf("did not expect a file for mob 43")
+ }
+}
diff --git a/internal/gametime/gametime.go b/internal/gametime/gametime.go
index 27c0059eb..315ae7a23 100644
--- a/internal/gametime/gametime.go
+++ b/internal/gametime/gametime.go
@@ -20,6 +20,26 @@ var (
roundDateCacheSeq []uint64
)
+// anchorPeriods maps the user-facing token (already lowercased and trimmed of any
+// "x" prefix down to its first three characters by AddPeriod) to the canonical
+// period name understood by GameDate.LastPeriod.
+//
+// These are "anchor" periods: rather than advancing by a fixed duration, they
+// first snap backwards to the most recent occurrence of a named moment (e.g. the
+// last noon, the last sunrise) and then advance forward by whole days. This is why
+// AddPeriod treats them differently from plain durations like "3 days".
+//
+// Note that "midnight" intentionally maps to the "day" anchor: midnight *is* the
+// start of a game day, so the last midnight is the last day-start.
+var anchorPeriods = map[string]string{
+ `noo`: `noon`, // "noon", "noons"
+ `mid`: `day`, // "midnight", "midnights" -> start of day
+ `sunrise`: `sunrise`, // matched in full because "sun" alone is ambiguous
+ `sunrises`: `sunrise`,
+ `sunset`: `sunset`,
+ `sunsets`: `sunset`,
+}
+
type RoundTimer struct {
RoundStart uint64 `yaml:"roundstart,omitempty"`
Period string `yaml:"period,omitempty"`
@@ -84,31 +104,32 @@ func (gd GameDate) String(symbolOnly ...bool) string {
return fmt.Sprintf("%d:%02d%s", dayNight, gd.Hour, gd.Minute, gd.AmPm)
}
-// Jumps the clock foward to the next night
-// If a roundAdjustment is provided, it will be added to the offset
-// This is useful to set to the round right before the rollover
+// SetToNight jumps the global clock forward to the next night.
+//
+// If a roundAdjustment is provided it is added to (or subtracted from) the target
+// round. This is useful to land on the round right before the rollover.
func SetToNight(roundAdjustment ...int) {
-
- dayRound := GetLastPeriod(`sunset`, util.GetRoundCount())
-
- if len(roundAdjustment) > 0 {
- if roundAdjustment[0] < 0 {
- dayRound -= uint64(-1 * roundAdjustment[0])
- } else {
- dayRound += uint64(roundAdjustment[0])
- }
- }
-
- gd := GetDate(dayRound).Add(0, 1, 0)
- util.SetRoundCount(gd.RoundNumber)
+ setToDayPart(`sunset`, roundAdjustment...)
}
-// Jumps the clock forward to the next day
-// If a roundAdjustment is provided, it will be added to the offset
-// This is useful to set to the round right before the rollover
+// SetToDay jumps the global clock forward to the next day.
+//
+// If a roundAdjustment is provided it is added to (or subtracted from) the target
+// round. This is useful to land on the round right before the rollover.
func SetToDay(roundAdjustment ...int) {
+ setToDayPart(`sunrise`, roundAdjustment...)
+}
- dayRound := GetLastPeriod(`sunrise`, util.GetRoundCount())
+// setToDayPart is the shared implementation behind SetToNight/SetToDay. Both
+// functions are identical except for the anchor they snap to (sunset vs sunrise),
+// so the logic lives here once:
+// - find the last occurrence of the anchor,
+// - apply the optional round adjustment,
+// - advance one day so we land on the *next* occurrence,
+// - write the result back to the global round counter.
+func setToDayPart(anchor string, roundAdjustment ...int) {
+
+ dayRound := GetLastPeriod(anchor, util.GetRoundCount())
if len(roundAdjustment) > 0 {
if roundAdjustment[0] < 0 {
@@ -122,8 +143,9 @@ func SetToDay(roundAdjustment ...int) {
util.SetRoundCount(gd.RoundNumber)
}
-// Jumps the clock forward a specific hour/minutes
-// Between 0 and 23
+// SetTime jumps the global clock forward to a specific hour (0-23) and optional
+// minute. It works by adjusting dayResetOffset so the next ReCalculate reports the
+// requested time, then clears the round-date cache so stale entries are not served.
func SetTime(setToHour int, setToMinutes ...int) {
rpd := activeCalendar[`default`].roundsPerDay
@@ -174,8 +196,15 @@ func GetDate(forceRound ...uint64) GameDate {
}
func getDate(currentRound uint64) GameDate {
+ return getDateForCalendar(currentRound, `default`)
+}
- calendarToUse := "default"
+// getDateForCalendar builds a fully-populated GameDate for a round under a
+// specific named calendar. getDate is the common "default" calendar case; the
+// anchor path in AddPeriod uses this directly with g.Calendar so that day-stepping
+// after a snap (e.g. "1 sunrise") respects the originating date's calendar rather
+// than silently reverting to "default".
+func getDateForCalendar(currentRound uint64, calendarToUse string) GameDate {
gd := GameDate{Calendar: calendarToUse}
@@ -294,263 +323,334 @@ func (g GameDate) Add(adjustHours int, adjustDays int, adjustYears int) GameDate
return g
}
-// Example:
-// gd := gametime.GetDate()
-// nextPeriodRound := gd.AddPeriod(`10 days`)
-// Accepts: x years, x months, x weeks, x days, x hours, x rounds
-// If `IRL` or `real` are in the mix, such as `x irl days` or `x days irl`, then it will use real world time
-func (g GameDate) AddPeriod(periodStr string) uint64 {
+// realTimeRounds converts a real-world quantity into a number of game rounds,
+// based on the configured real seconds per round. It is used by AddPeriod when a
+// period string contains "real" or "irl".
+//
+// Note: a "real day" is treated as 84600 seconds (23.5 hours), not 86400. This is
+// a long-standing intentional quirk of this engine, preserved here so existing
+// content that relies on it keeps the same timing.
+type realTimeRounds struct {
+ perMinute int
+ perHour int
+ perDay int
+}
- if periodStr == `` {
- return g.RoundNumber
+func newRealTimeRounds() realTimeRounds {
+ // RoundSeconds is a real-time value — read from the timing config, not the calendar.
+ roundSeconds := int(configs.GetTimingConfig().RoundSeconds)
+ if roundSeconds < 1 {
+ roundSeconds = 1
}
+ return realTimeRounds{
+ perMinute: 60 / roundSeconds,
+ perHour: 3600 / roundSeconds,
+ perDay: 84600 / roundSeconds,
+ }
+}
- qty := 1
- timeStr := ``
- realTime := false
- roundsPerRealDay := 0
- roundsPerRealHour := 0
- roundsPerRealMinute := 0
-
- parts := strings.Split(strings.ToLower(periodStr), ` `)
- if len(parts) == 1 { // e.g. 2
-
- // try and parse a number, if not a number, must be a str
- if qty, _ = strconv.Atoi(parts[0]); qty < 1 {
- qty = 1
- timeStr = parts[0]
- }
-
- } else if len(parts) == 2 { // e.g. - 2 days
- // first arg is quantity, second is unit
- if qty, _ = strconv.Atoi(parts[0]); qty < 1 {
- qty = 1
- }
- timeStr = parts[1]
-
- } else if len(parts) == 3 {
-
- // first arg is quantity, second should be `real` and the last is the unit
- if qty, _ = strconv.Atoi(parts[0]); qty < 1 {
- qty = 1
- }
-
- // RoundSeconds is a real-time value — still read from the timing config (not the calendar).
- c := configs.GetTimingConfig()
-
- if parts[1] == `real` || parts[1] == `irl` { // e.g. - 2 irl days
- realTime = true
- roundsPerRealDay = 84600 / int(c.RoundSeconds)
- roundsPerRealHour = 3600 / int(c.RoundSeconds)
- roundsPerRealMinute = 60 / int(c.RoundSeconds)
-
- timeStr = parts[2]
- } else if parts[1] == `game` || parts[1] == `gametime` { // e.g. - 2 game days
- timeStr = parts[2]
- } else if parts[2] == `real` || parts[2] == `irl` { // e.g. - 2 days irl
- realTime = true
- roundsPerRealDay = 84600 / int(c.RoundSeconds)
- roundsPerRealHour = 3600 / int(c.RoundSeconds)
- roundsPerRealMinute = 60 / int(c.RoundSeconds)
-
- timeStr = parts[1]
- } else if parts[2] == `game` || parts[2] == `gametime` { // e.g. - 2 days gametime
- timeStr = parts[1]
- }
+// AddPeriod returns the round number reached by advancing FORWARD from this
+// GameDate by the supplied period string. It is the additive counterpart to
+// StartOf (which snaps backward).
+//
+// Example:
+//
+// gd := gametime.GetDate()
+// nextPeriodRound := gd.AddPeriod(`10 days`)
+//
+// Accepts: x years, x months, x weeks, x days, x hours, x minutes, x rounds, and
+// the day-anchor units noon/midnight/sunrise/sunset (which snap to the last such
+// moment and then add x days).
+//
+// If `IRL` or `real` appear in the string, such as `x irl days` or `x days irl`,
+// real-world time is used instead of game time. Real time is not supported for the
+// day-anchor units; specifying it logs an error and falls back to game time.
+func (g GameDate) AddPeriod(periodStr string) uint64 {
+ qty, timeStr, realTime := parsePeriod(periodStr)
+ if timeStr == `` && qty == 0 {
+ // Empty / unparseable-as-anything input: no movement.
+ return g.RoundNumber
}
if len(timeStr) >= 3 {
strShort := timeStr[0:3]
- if strShort == `yea` { // timeStr == `year` || timeStr == `years` || timeStr == `yearly` {
-
- if realTime {
- adjustment := uint64(qty * roundsPerRealDay * 365)
- return g.RoundNumber + adjustment
- }
-
- gNext := g.Add(0, 0, 1*qty)
-
- return gNext.RoundNumber
-
- } else if strShort == `mon` { // else if timeStr == `month` || timeStr == `months` || timeStr == `monthly` {
-
+ // --- Day-anchor units (noon/midnight/sunrise/sunset) ---------------------
+ // These do not advance by a fixed duration. They first snap backward to the
+ // last occurrence of the anchor, then advance forward by qty whole days.
+ // Handled first, and via a shared table, so the four near-identical branches
+ // that previously existed are collapsed into one.
+ anchorKey := strShort
+ if anchorKey != `noo` && anchorKey != `mid` {
+ // sunrise/sunset are matched in full (their first three letters, "sun",
+ // are ambiguous), so use the whole token for the lookup.
+ anchorKey = timeStr
+ }
+ if anchor, ok := anchorPeriods[anchorKey]; ok {
if realTime {
- adjustment := uint64(qty * roundsPerRealHour * 730)
- return g.RoundNumber + adjustment
+ mudlog.Error("AddPeriod", "error", "real time not supported for "+timeStr)
}
+ anchored := getDateForCalendar(g.LastPeriod(anchor), g.Calendar)
+ return anchored.Add(0, qty, 0).RoundNumber
+ }
- hoursPerMonth := activeCalendar[g.Calendar].hoursPerMonth
- gNext := g.Add(int(math.Round(hoursPerMonth))*qty, 0, 0)
-
- return gNext.RoundNumber
-
- } else if strShort == `wee` { // else if timeStr == `week` || timeStr == `weeks` || timeStr == `weekly` {
+ // --- Fixed-duration units -----------------------------------------------
+ switch strShort {
+ case `yea`: // year / years / yearly
if realTime {
- adjustment := uint64(qty * roundsPerRealDay * 7)
- return g.RoundNumber + adjustment
+ rt := newRealTimeRounds()
+ return g.RoundNumber + uint64(qty*rt.perDay*365)
}
+ return g.Add(0, 0, qty).RoundNumber
- gNext := g.Add(0, activeCalendar[g.Calendar].daysPerWeek*qty, 0)
-
- return gNext.RoundNumber
-
- } else if strShort == `day` || strShort == `dai` { // else if timeStr == `day` || timeStr == `days` || timeStr == `daily` {
-
+ case `mon`: // month / months / monthly
if realTime {
- adjustment := uint64(qty * roundsPerRealDay)
- return g.RoundNumber + adjustment
+ rt := newRealTimeRounds()
+ return g.RoundNumber + uint64(qty*rt.perHour*730)
}
+ hoursPerMonth := activeCalendar[g.Calendar].hoursPerMonth
+ return g.Add(int(math.Round(hoursPerMonth))*qty, 0, 0).RoundNumber
- gNext := g.Add(0, qty, 0)
-
- return gNext.RoundNumber
-
- } else if strShort == `hou` { // if timeStr == `hour` || timeStr == `hours` || timeStr == `hourly` {
-
+ case `wee`: // week / weeks / weekly
if realTime {
- adjustment := uint64(qty * roundsPerRealHour)
- return g.RoundNumber + adjustment
+ rt := newRealTimeRounds()
+ return g.RoundNumber + uint64(qty*rt.perDay*7)
}
+ return g.Add(0, activeCalendar[g.Calendar].daysPerWeek*qty, 0).RoundNumber
- gNext := g.Add(qty, 0, 0)
-
- return gNext.RoundNumber
-
- } else if strShort == `min` { // if timeStr == `minute` || if timeStr == `minutes` || if timeStr == `minutely`
-
+ case `day`, `dai`: // day / days / daily
if realTime {
- adjustment := uint64(qty * roundsPerRealMinute)
- return g.RoundNumber + adjustment
+ rt := newRealTimeRounds()
+ return g.RoundNumber + uint64(qty*rt.perDay)
}
+ return g.Add(0, qty, 0).RoundNumber
- return g.RoundNumber + uint64(math.Floor(float64(qty)*activeCalendar[g.Calendar].roundsPerMinute))
-
- } else if strShort == `noo` { // if timeStr == `noon` || timeStr == `noons` {
-
+ case `hou`: // hour / hours / hourly
if realTime {
- mudlog.Error("AddPeriod", "error", "real time not supported for noon yet: "+timeStr)
+ rt := newRealTimeRounds()
+ return g.RoundNumber + uint64(qty*rt.perHour)
}
+ return g.Add(qty, 0, 0).RoundNumber
- g = getDate(GetLastPeriod(`noon`, g.RoundNumber))
- // adjusts by days
- gNext := g.Add(0, qty, 0)
-
- return gNext.RoundNumber
-
- } else if strShort == `mid` { // if timeStr == `midnight` || timeStr == `midnights` {
-
+ case `min`: // minute / minutes
if realTime {
- mudlog.Error("AddPeriod", "error", "real time not supported for midnight yet: "+timeStr)
+ rt := newRealTimeRounds()
+ return g.RoundNumber + uint64(qty*rt.perMinute)
}
+ return g.RoundNumber + uint64(math.Floor(float64(qty)*activeCalendar[g.Calendar].roundsPerMinute))
+ }
- g = getDate(GetLastPeriod(`day`, g.RoundNumber))
- // adjusts by days
- gNext := g.Add(0, qty, 0)
-
- return gNext.RoundNumber
-
- } else if timeStr == `sunrise` || timeStr == `sunrises` {
-
- if realTime {
- mudlog.Error("AddPeriod", "error", "real time not supported for sunrise yet: "+timeStr)
- }
+ // Unrecognised unit: fail over to treating qty as raw rounds.
+ return g.RoundNumber + uint64(qty)
+ }
- g = getDate(GetLastPeriod(`sunrise`, g.RoundNumber))
- // adjusts by days
- gNext := g.Add(0, qty, 0)
+ // No unit string at all (e.g. just a number): treat qty as hours, matching the
+ // historical default behaviour.
+ return g.Add(qty, 0, 0).RoundNumber
+}
- return gNext.RoundNumber
+// parsePeriod breaks a period string such as "2 days", "3 irl hours" or "5" into
+// its component quantity, unit token and a real-time flag.
+//
+// Supported shapes:
+//
+// "" e.g. "day" -> qty 1
+// "" e.g. "5" -> raw rounds (no unit)
+// "" e.g. "2 days"
+// "" e.g. "2 irl days"
+// "" e.g. "2 days irl"
+// "" e.g. "2 game days" (explicit game time)
+// "" e.g. "2 days gametime"
+//
+// qty defaults to 1 whenever it is missing or not a positive integer.
+func parsePeriod(periodStr string) (qty int, timeStr string, realTime bool) {
+
+ qty = 1
- } else if timeStr == `sunset` || timeStr == `sunsets` {
+ if periodStr == `` {
+ return 0, ``, false
+ }
- if realTime {
- mudlog.Error("AddPeriod", "error", "real time not supported for sunset yet: "+timeStr)
- }
+ parts := strings.Split(strings.ToLower(periodStr), ` `)
- g = getDate(GetLastPeriod(`sunset`, g.RoundNumber))
- // adjusts by days
- gNext := g.Add(0, qty, 0)
+ switch len(parts) {
- return gNext.RoundNumber
+ case 1: // either a bare number, or a bare unit
+ // Try to parse a number; if that fails (or is < 1) it must be a unit string.
+ if n, err := strconv.Atoi(parts[0]); err == nil && n >= 1 {
+ qty = n
+ } else {
+ timeStr = parts[0]
+ }
+ case 2: // ""
+ if n, _ := strconv.Atoi(parts[0]); n >= 1 {
+ qty = n
}
+ timeStr = parts[1]
- // Failover to rounds
- return g.RoundNumber + uint64(qty)
+ case 3: // "" or ""
+ if n, _ := strconv.Atoi(parts[0]); n >= 1 {
+ qty = n
+ }
+ switch {
+ case parts[1] == `real` || parts[1] == `irl`:
+ realTime = true
+ timeStr = parts[2]
+ case parts[1] == `game` || parts[1] == `gametime`:
+ timeStr = parts[2]
+ case parts[2] == `real` || parts[2] == `irl`:
+ realTime = true
+ timeStr = parts[1]
+ case parts[2] == `game` || parts[2] == `gametime`:
+ timeStr = parts[1]
+ }
}
- // Assume rounds?
- //if timeStr == `hour` || timeStr == `hours` || timeStr == `hourly` {
-
- gNext := g.Add(qty, 0, 0)
-
- return gNext.RoundNumber
+ return qty, timeStr, realTime
+}
- //}
+// StartOf snaps BACKWARD to the start of the named period relative to this
+// GameDate, returning that round number. It is the backward-snapping counterpart
+// to AddPeriod and never returns a round later than g.RoundNumber.
+//
+// It accepts both bare period names ("hour", "day", "week", "month", "year",
+// "noon", "sunrise", "sunset") and the natural-language "start of X" forms used by
+// player/scripting input. The connective words "start", "of" and "the" are
+// stripped during normalisation, so all of these are equivalent:
+//
+// "start of the hour" "start of hour" "start hour" "hour"
+//
+// IMPORTANT: this is a backward operation. Callers that expect a future expiry
+// round (the common AddPeriod use case) must NOT route "start of X" strings here
+// expecting forward movement — the result will be in the past or present.
+func (g GameDate) StartOf(periodStr string) uint64 {
+ name := normalizeStartOf(periodStr)
+ if name == `` {
+ // Nothing recognisable to snap to; stay put.
+ return g.RoundNumber
+ }
+ return g.LastPeriod(name)
+}
+// normalizeStartOf lowercases the input and removes the connective tokens
+// "start", "of" and "the", leaving just the period name. e.g. "Start Of The Day"
+// becomes "day". Returns "" if nothing is left.
+func normalizeStartOf(periodStr string) string {
+ parts := strings.Fields(strings.ToLower(periodStr))
+ kept := parts[:0]
+ for _, p := range parts {
+ switch p {
+ case `start`, `of`, `the`:
+ // drop connective words
+ default:
+ kept = append(kept, p)
+ }
+ }
+ if len(kept) == 0 {
+ return ``
+ }
+ // Only the period name is meaningful; ignore any trailing words.
+ return kept[0]
}
-func GetLastPeriod(periodName string, roundNumber uint64) uint64 {
+// LastPeriod returns the round number at which the named period last began,
+// relative to this GameDate's round number, honouring this GameDate's calendar.
+//
+// This is the calendar-aware core used by both StartOf and AddPeriod's day-anchor
+// handling. Previously this logic lived only in the package-level GetLastPeriod,
+// which always used the "default" calendar; making it a method ensures a non-
+// default calendar's AddPeriod("1 sunrise") and StartOf calls compute against the
+// correct calendar.
+//
+// Supported names: hour, day (== midnight), week, month, year, noon, sunrise,
+// sunset. Unknown names return the round number unchanged.
+func (g GameDate) LastPeriod(periodName string) uint64 {
- ac := activeCalendar[`default`]
+ ac := activeCalendar[g.Calendar]
+ roundNumber := g.RoundNumber
roundsPerDay := ac.roundsPerDay
nightHoursPerDay := uint64(ac.nightHours)
roundsPerHour := ac.roundsPerHour
noonRound := ac.noonRound
- // What round started this week?
+ // Offsets of the current round within each enclosing period.
roundOfWeek := roundNumber % ac.roundsPerWeek
+ roundOfDay := roundNumber % roundsPerDay // since midnight
+ roundOfHour := roundOfDay % uint64(math.Floor(roundsPerHour)) // since top of hour
- // What round started this day? (midnight)
- roundOfDay := roundNumber % roundsPerDay
-
- // What round started this hour?
- roundOfHour := roundOfDay % uint64(math.Floor(roundsPerHour))
-
- if periodName == `hour` { // Start of the current hour (or closest to it)
+ switch periodName {
+ case `hour`: // start of the current hour
roundNumber -= roundOfHour
- } else if periodName == `day` { // Start of current day
-
+ case `day`, `midnight`: // start of the current day (midnight)
roundNumber -= roundOfDay
- } else if periodName == `week` { // Start of current week
-
- roundNumber -= roundOfWeek // First go to the start of the day
+ case `week`: // start of the current week
+ roundNumber -= roundOfWeek
+
+ case `month`: // start of the current month
+ // Walk back to the most recent round whose day is the first of the month.
+ // hoursPerMonth is fractional in general, so there is no closed-form round
+ // offset; instead snap to midnight and step whole days backward until the
+ // month rolls over. Bounded by the days in a month, so it is cheap.
+ roundNumber -= roundOfDay // first go to midnight today
+ startMonth := getDateForCalendar(roundNumber, g.Calendar).Month
+ for roundNumber >= roundsPerDay {
+ prev := roundNumber - roundsPerDay
+ if getDateForCalendar(prev, g.Calendar).Month != startMonth {
+ break
+ }
+ roundNumber = prev
+ }
- } else if periodName == `noon` { // Last time 12pm was hit
+ case `year`: // start of the current year
+ // day is 1-based within the year, so (day-1) whole days have elapsed.
+ roundNumber -= roundOfDay // midnight today
+ dayOfYear := uint64(getDateForCalendar(roundNumber, g.Calendar).Day)
+ if dayOfYear > 1 {
+ roundNumber -= (dayOfYear - 1) * roundsPerDay
+ }
+ case `noon`: // last time 12pm was reached
roundNumber -= roundOfDay
if roundOfDay < noonRound {
- // We haven't reached noon today yet; last noon was yesterday.
+ // Noon has not happened yet today; the last noon was yesterday.
roundNumber -= roundsPerDay - noonRound
} else {
- // Noon has already passed today.
+ // Noon already passed today.
roundNumber += noonRound
}
- } else if periodName == `sunrise` { // last sunrise
-
- roundNumber -= roundOfDay // Strip rounds of today off
- roundNumber -= roundsPerDay // Subtract a day
+ case `sunrise`: // last sunrise (start of day + half the night)
+ roundNumber -= roundOfDay // strip today's rounds
+ roundNumber -= roundsPerDay // back up a day
roundNumber += uint64(math.Ceil(float64(nightHoursPerDay) / 2 * roundsPerHour)) // add half a night
- } else if periodName == `sunset` { // 12am of next day, minus half of night
-
- roundNumber -= roundOfDay // Strip rounds of today off
- roundNumber -= uint64(math.Ceil(float64(nightHoursPerDay) / 2 * roundsPerHour)) // Subtract half a night
-
+ case `sunset`: // last sunset (next midnight minus half the night)
+ roundNumber -= roundOfDay // strip today's rounds
+ roundNumber -= uint64(math.Ceil(float64(nightHoursPerDay) / 2 * roundsPerHour)) // subtract half a night
}
return roundNumber
}
+// GetLastPeriod is a package-level convenience wrapper around GameDate.LastPeriod
+// for the "default" calendar. It is retained for callers that only have a round
+// number on hand (e.g. SetToNight/SetToDay) and do not need calendar selection.
+//
+// Prefer GameDate.LastPeriod when you already hold a GameDate, as it respects that
+// date's calendar.
+func GetLastPeriod(periodName string, roundNumber uint64) uint64 {
+ g := GameDate{Calendar: `default`, RoundNumber: roundNumber}
+ return g.LastPeriod(periodName)
+}
+
func MonthName(month int) string {
names := activeCalendar[`default`].monthNames
if len(names) == 0 {
diff --git a/internal/gametime/gametime_test.go b/internal/gametime/gametime_test.go
index 31e92f242..4f454e808 100644
--- a/internal/gametime/gametime_test.go
+++ b/internal/gametime/gametime_test.go
@@ -361,8 +361,99 @@ func Test_AddPeriod_Noon(t *testing.T) {
}
}
-// --- applyCalendarConfigInto clamp tests ---
+// --- LastPeriod (month/year) and StartOf ---
+
+func Test_LastPeriod_MatchesPackageWrapper(t *testing.T) {
+ seedCalendarConfig(t, defaultTestCalendar(240, 0))
+
+ // The package-level wrapper must agree with the method for the default calendar.
+ for _, name := range []string{"hour", "day", "week", "noon", "sunrise", "sunset"} {
+ g := GameDate{Calendar: `default`, RoundNumber: 1700}
+ if g.LastPeriod(name) != GetLastPeriod(name, 1700) {
+ t.Errorf("LastPeriod(%q) != GetLastPeriod(%q): %d vs %d",
+ name, name, g.LastPeriod(name), GetLastPeriod(name, 1700))
+ }
+ }
+}
+
+func Test_LastPeriod_Year(t *testing.T) {
+ seedCalendarConfig(t, defaultTestCalendar(240, 0))
+
+ // 240 rounds/day, 365 days/year => year start at round 0 for year 1.
+ // Pick a round mid-year (day 100, 50 rounds in) and confirm it snaps to round 0.
+ round := uint64(99*240 + 50)
+ g := GameDate{Calendar: `default`, RoundNumber: round}
+ if got := g.LastPeriod("year"); got != 0 {
+ t.Errorf("LastPeriod(year) from round %d: got %d, want 0", round, got)
+ }
+
+ // A round early in year 2 should snap to the first round of year 2.
+ roundY2 := uint64(365*240 + 30)
+ g2 := GameDate{Calendar: `default`, RoundNumber: roundY2}
+ if got := g2.LastPeriod("year"); got != uint64(365*240) {
+ t.Errorf("LastPeriod(year) from round %d: got %d, want %d", roundY2, got, 365*240)
+ }
+}
+
+func Test_LastPeriod_Month(t *testing.T) {
+ seedCalendarConfig(t, defaultTestCalendar(240, 0))
+
+ // Snapping to the start of a month must land on a midnight whose month matches
+ // the starting round's month, and the round just before it must be a different month.
+ round := uint64(40 * 240) // day 41, midnight
+ g := GameDate{Calendar: `default`, RoundNumber: round}
+ start := g.LastPeriod("month")
+
+ startMonth := GetDate(start).Month
+ if GetDate(round).Month != startMonth {
+ t.Fatalf("month start month %d does not match origin month %d", startMonth, GetDate(round).Month)
+ }
+ if start%240 != 0 {
+ t.Errorf("month start round %d is not aligned to midnight", start)
+ }
+ if start > 0 && GetDate(start-240).Month == startMonth {
+ t.Errorf("round before month start is still month %d; not the true start", startMonth)
+ }
+}
+
+func Test_StartOf_NormalizesConnectiveWords(t *testing.T) {
+ seedCalendarConfig(t, defaultTestCalendar(240, 0))
+ g := GameDate{Calendar: `default`, RoundNumber: 255}
+ want := g.LastPeriod("hour") // 250
+
+ // All of these phrasings must resolve to the same start-of-hour round.
+ for _, phrase := range []string{"hour", "start hour", "start of hour", "start of the hour"} {
+ if got := g.StartOf(phrase); got != want {
+ t.Errorf("StartOf(%q): got %d, want %d", phrase, got, want)
+ }
+ }
+}
+
+func Test_StartOf_NeverMovesForward(t *testing.T) {
+ seedCalendarConfig(t, defaultTestCalendar(240, 0))
+
+ g := GameDate{Calendar: `default`, RoundNumber: 1234}
+ for _, name := range []string{"hour", "day", "week", "month", "year"} {
+ if got := g.StartOf(name); got > g.RoundNumber {
+ t.Errorf("StartOf(%q) = %d moved forward past %d", name, got, g.RoundNumber)
+ }
+ }
+}
+
+func Test_StartOf_UnknownReturnsUnchanged(t *testing.T) {
+ seedCalendarConfig(t, defaultTestCalendar(240, 0))
+
+ g := GameDate{Calendar: `default`, RoundNumber: 777}
+ if got := g.StartOf(""); got != 777 {
+ t.Errorf("StartOf(empty): got %d, want 777", got)
+ }
+ if got := g.StartOf("start of the"); got != 777 {
+ t.Errorf("StartOf(connectives only): got %d, want 777", got)
+ }
+}
+
+// --- applyCalendarConfigInto clamp tests ---
func Test_ApplyCalendarConfig_ClampsLowRoundsPerDay(t *testing.T) {
// rounds_per_day values below 24 must be replaced with the failover value
// so that roundsPerHour >= 1 and GetLastPeriod never divides by zero.
diff --git a/internal/mobs/mobs.go b/internal/mobs/mobs.go
index 592f2baea..ee22d9cd7 100644
--- a/internal/mobs/mobs.go
+++ b/internal/mobs/mobs.go
@@ -687,6 +687,10 @@ func (r *Mob) Save() error {
func (m *Mob) HasScript() bool {
+ if script := getPluginScript(int(m.MobId), m.ScriptTag); script != `` {
+ return true
+ }
+
scriptPath := m.GetScriptPath()
// Load the script into a string
if _, err := os.Stat(scriptPath); err == nil {
@@ -698,6 +702,10 @@ func (m *Mob) HasScript() bool {
func (m *Mob) GetScript() string {
+ if script := getPluginScript(int(m.MobId), m.ScriptTag); script != `` {
+ return script
+ }
+
scriptPath := m.GetScriptPath()
// Load the script into a string
if _, err := os.Stat(scriptPath); err == nil {
@@ -840,6 +848,10 @@ func LoadDataFiles() {
mobs = tmpMobs
+ // Merge mobs from plugin file systems before populating name caches so
+ // allMobNames and mobNameCache include plugin-provided mobs.
+ loadPluginMobs(mobs)
+
clear(mobNameCache)
for _, mob := range mobs {
diff --git a/internal/mobs/plugin.go b/internal/mobs/plugin.go
new file mode 100644
index 000000000..54662ef61
--- /dev/null
+++ b/internal/mobs/plugin.go
@@ -0,0 +1,123 @@
+package mobs
+
+import (
+ "io/fs"
+ "strings"
+
+ "github.com/GoMudEngine/GoMud/internal/fileloader"
+ "github.com/GoMudEngine/GoMud/internal/mudlog"
+ "gopkg.in/yaml.v2"
+)
+
+// mobScriptKey identifies an embedded mob script by mob id and script tag.
+// An empty tag refers to the mob's base (untagged) script.
+type mobScriptKey struct {
+ mobId int
+ tag string
+}
+
+var (
+ pluginFileSystems []fileloader.ReadableGroupFS
+ pluginScripts = map[mobScriptKey]string{} // (mobId, tag) -> JS source
+)
+
+// RegisterFS registers a plugin file system to be searched when loading mob
+// data files. Must be called before LoadDataFiles().
+func RegisterFS(f ...fileloader.ReadableGroupFS) {
+ pluginFileSystems = append(pluginFileSystems, f...)
+}
+
+// RegisterMobScript registers an embedded JS script for a given mob ID and
+// script tag (empty tag = base script). This is used by modules that embed
+// their scripts rather than placing them on disk alongside the YAML definition.
+//
+// Note: embedded mob scripts are not enumerated by GetAllScriptTags(), so
+// module-provided mob scripts are not editable through the admin script-tag UI.
+func RegisterMobScript(mobId int, tag string, script string) {
+ pluginScripts[mobScriptKey{mobId: mobId, tag: tag}] = script
+}
+
+// getPluginScript returns the registered plugin script for (mobId, tag), or "".
+func getPluginScript(mobId int, tag string) string {
+ return pluginScripts[mobScriptKey{mobId: mobId, tag: tag}]
+}
+
+// loadPluginMobs walks every sub-filesystem of every registered plugin FS,
+// reading mob YAML files from a "mobs/" prefix and merging them into dst.
+// Disk-loaded mobs take precedence: a plugin mob with a duplicate id is logged
+// and skipped.
+//
+// Mob spec files live under "mobs//-.yaml". Files under a
+// "scripts/" segment are ignored so script-adjacent yaml is not mistaken for a
+// mob spec.
+func loadPluginMobs(dst map[int]*Mob) {
+ for _, groupFS := range pluginFileSystems {
+ for subFS := range groupFS.AllFileSubSystems {
+ loadMobsFromFS(subFS, dst)
+ }
+ }
+}
+
+func loadMobsFromFS(subFS fs.ReadFileFS, dst map[int]*Mob) {
+ if pl, ok := subFS.(fileloader.PathLister); ok {
+ for _, path := range pl.KnownPaths() {
+ if !isMobSpecPath(path) {
+ continue
+ }
+ loadMobFileFromFS(subFS, path, dst)
+ }
+ return
+ }
+
+ _ = fs.WalkDir(subFS, `mobs`, func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() || !isMobSpecPath(path) {
+ return nil
+ }
+ loadMobFileFromFS(subFS, path, dst)
+ return nil
+ })
+}
+
+func isMobSpecPath(path string) bool {
+ if !strings.HasPrefix(path, `mobs/`) || !strings.HasSuffix(path, `.yaml`) {
+ return false
+ }
+ if strings.Contains(path, `/scripts/`) {
+ return false
+ }
+ return true
+}
+
+func loadMobFileFromFS(subFS fs.ReadFileFS, path string, dst map[int]*Mob) {
+ b, err := subFS.ReadFile(path)
+ if err != nil {
+ mudlog.Error("mobs.loadMobsFromFS", "path", path, "error", err)
+ return
+ }
+
+ var mob Mob
+ if err := yaml.Unmarshal(b, &mob); err != nil {
+ mudlog.Error("mobs.loadMobsFromFS", "path", path, "error", err)
+ return
+ }
+
+ // During load mobNameCache is not yet populated for this mob, so Filepath()
+ // derives the filename from Character.Name. The embedded file must be named
+ // "/-.yaml" to satisfy this.
+ if !strings.HasSuffix(path, mob.Filepath()) {
+ mudlog.Error("mobs.loadMobsFromFS", "path", path, "expected suffix", mob.Filepath(), "error", "filepath mismatch")
+ return
+ }
+
+ if err := mob.Validate(); err != nil {
+ mudlog.Error("mobs.loadMobsFromFS", "path", path, "error", err)
+ return
+ }
+
+ if _, exists := dst[mob.Id()]; exists {
+ mudlog.Error("mobs.loadMobsFromFS", "mobId", mob.Id(), "path", path, "error", "duplicate mob id")
+ return
+ }
+
+ dst[mob.Id()] = &mob
+}
diff --git a/internal/mobs/plugin_test.go b/internal/mobs/plugin_test.go
new file mode 100644
index 000000000..385e59e21
--- /dev/null
+++ b/internal/mobs/plugin_test.go
@@ -0,0 +1,149 @@
+package mobs
+
+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[mobScriptKey]string{}
+}
+
+func TestIsMobSpecPath(t *testing.T) {
+ cases := []struct {
+ path string
+ want bool
+ }{
+ {`mobs/testzone/9001-goblin.yaml`, true},
+ {`mobs/9001-goblin.yaml`, true},
+ {`mobs/testzone/scripts/9001-goblin.js`, false},
+ {`mobs/testzone/scripts/9001-goblin.yaml`, false},
+ {`items/9001-goblin.yaml`, false},
+ {`mobs/testzone/9001-goblin.js`, false},
+ }
+ for _, c := range cases {
+ if got := isMobSpecPath(c.path); got != c.want {
+ t.Errorf("isMobSpecPath(%q) = %v, want %v", c.path, got, c.want)
+ }
+ }
+}
+
+func TestLoadPluginMobs_MergesValidMob(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ // zone "testzone", mobId 9001, Character.Name "Plugin Goblin" =>
+ // Filepath() = testzone/9001-plugin_goblin.yaml
+ yamlData := []byte("mobid: 9001\nzone: testzone\ncharacter:\n name: Plugin Goblin\n")
+ RegisterFS(newFakeFS(map[string][]byte{
+ `mobs/testzone/9001-plugin_goblin.yaml`: yamlData,
+ // A script-adjacent yaml that must be ignored.
+ `mobs/testzone/scripts/9001-plugin_goblin.yaml`: []byte("mobid: 9999\n"),
+ }))
+
+ dst := map[int]*Mob{}
+ loadPluginMobs(dst)
+
+ mob, ok := dst[9001]
+ if !ok {
+ t.Fatalf("expected mob 9001 to be loaded")
+ }
+ if mob.Character.Name != "Plugin Goblin" {
+ t.Fatalf("expected name %q, got %q", "Plugin Goblin", mob.Character.Name)
+ }
+ if _, ok := dst[9999]; ok {
+ t.Fatalf("script-adjacent yaml should have been ignored")
+ }
+}
+
+func TestLoadPluginMobs_RejectsFilepathMismatch(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ RegisterFS(newFakeFS(map[string][]byte{
+ `mobs/testzone/wrong-name.yaml`: []byte("mobid: 9002\nzone: testzone\ncharacter:\n name: Plugin Goblin\n"),
+ }))
+
+ dst := map[int]*Mob{}
+ loadPluginMobs(dst)
+
+ if len(dst) != 0 {
+ t.Fatalf("expected mismatched-filepath mob to be skipped, got %d", len(dst))
+ }
+}
+
+func TestLoadPluginMobs_DiskWinsOnDuplicate(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ RegisterFS(newFakeFS(map[string][]byte{
+ `mobs/testzone/9003-plugin_goblin.yaml`: []byte("mobid: 9003\nzone: testzone\ncharacter:\n name: Plugin Goblin\n"),
+ }))
+
+ existing := &Mob{MobId: 9003}
+ existing.Character.Name = "Disk Goblin"
+ dst := map[int]*Mob{9003: existing}
+
+ loadPluginMobs(dst)
+
+ if dst[9003].Character.Name != "Disk Goblin" {
+ t.Fatalf("expected disk mob to win, got %q", dst[9003].Character.Name)
+ }
+}
+
+func TestRegisterMobScript_ReturnedByGetScript(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ RegisterMobScript(9004, "", "onIdle()")
+ RegisterMobScript(9004, "combat", "onCombat()")
+
+ base := &Mob{MobId: 9004}
+ if got := base.GetScript(); got != "onIdle()" {
+ t.Fatalf("expected base plugin script, got %q", got)
+ }
+ if !base.HasScript() {
+ t.Fatalf("expected HasScript true for base plugin script")
+ }
+
+ tagged := &Mob{MobId: 9004, ScriptTag: "combat"}
+ if got := tagged.GetScript(); got != "onCombat()" {
+ t.Fatalf("expected tagged plugin script, got %q", got)
+ }
+}
diff --git a/internal/pets/pets.go b/internal/pets/pets.go
index c523217c7..4931f7eb7 100644
--- a/internal/pets/pets.go
+++ b/internal/pets/pets.go
@@ -395,12 +395,19 @@ func (p *Pet) GetScriptPath() string {
}
func (p *Pet) HasScript() bool {
+ if script := getPluginScript(p.Type); script != `` {
+ return true
+ }
scriptPath := p.GetScriptPath()
_, err := os.Stat(scriptPath)
return err == nil
}
func (p *Pet) GetScript() string {
+ // Check plugin-registered scripts first.
+ if script := getPluginScript(p.Type); script != `` {
+ return script
+ }
scriptPath := p.GetScriptPath()
if _, err := os.Stat(scriptPath); err == nil {
if bytes, err := util.ReadFile(scriptPath); err == nil {
@@ -464,5 +471,8 @@ func LoadDataFiles() {
petTypes = tmpPetTypes
+ // Merge pets from plugin file systems.
+ loadPluginPets(petTypes)
+
mudlog.Info("pets.LoadDataFiles()", "loadedCount", len(petTypes), "Time Taken", time.Since(start))
}
diff --git a/internal/pets/plugin.go b/internal/pets/plugin.go
new file mode 100644
index 000000000..7c8843fc1
--- /dev/null
+++ b/internal/pets/plugin.go
@@ -0,0 +1,96 @@
+package pets
+
+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[string]string{} // pet type -> JS source
+)
+
+// RegisterFS registers a plugin file system to be searched when loading pet
+// data files. Must be called before LoadDataFiles().
+func RegisterFS(f ...fileloader.ReadableGroupFS) {
+ pluginFileSystems = append(pluginFileSystems, f...)
+}
+
+// RegisterPetScript registers an embedded JS script for a given pet type.
+// This is used by modules that embed their scripts rather than placing them
+// on disk alongside the YAML definition.
+func RegisterPetScript(petType string, script string) {
+ pluginScripts[petType] = script
+}
+
+// getPluginScript returns the registered plugin script for petType, or "".
+func getPluginScript(petType string) string {
+ return pluginScripts[petType]
+}
+
+// loadPluginPets walks every sub-filesystem of every registered plugin FS,
+// reading pet YAML files from a "pets/" prefix and merging them into dst.
+// Disk-loaded pets take precedence: a plugin pet with a duplicate type is
+// logged and skipped.
+func loadPluginPets(dst map[string]*Pet) {
+ for _, groupFS := range pluginFileSystems {
+ for subFS := range groupFS.AllFileSubSystems {
+ loadPetsFromFS(subFS, dst)
+ }
+ }
+}
+
+func loadPetsFromFS(subFS fs.ReadFileFS, dst map[string]*Pet) {
+ if pl, ok := subFS.(fileloader.PathLister); ok {
+ for _, path := range pl.KnownPaths() {
+ if !strings.HasPrefix(path, `pets/`) || !strings.HasSuffix(path, `.yaml`) {
+ continue
+ }
+ loadPetFileFromFS(subFS, path, dst)
+ }
+ return
+ }
+
+ _ = fs.WalkDir(subFS, `pets`, func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() || !strings.HasSuffix(path, `.yaml`) {
+ return nil
+ }
+ loadPetFileFromFS(subFS, path, dst)
+ return nil
+ })
+}
+
+func loadPetFileFromFS(subFS fs.ReadFileFS, path string, dst map[string]*Pet) {
+ b, err := subFS.ReadFile(path)
+ if err != nil {
+ mudlog.Error("pets.loadPetsFromFS", "path", path, "error", err)
+ return
+ }
+
+ var pet Pet
+ if err := yaml.Unmarshal(b, &pet); err != nil {
+ mudlog.Error("pets.loadPetsFromFS", "path", path, "error", err)
+ return
+ }
+
+ if !strings.HasSuffix(path, pet.Filepath()) {
+ mudlog.Error("pets.loadPetsFromFS", "path", path, "expected suffix", pet.Filepath(), "error", "filepath mismatch")
+ return
+ }
+
+ if err := pet.Validate(); err != nil {
+ mudlog.Error("pets.loadPetsFromFS", "path", path, "error", err)
+ return
+ }
+
+ if _, exists := dst[pet.Id()]; exists {
+ mudlog.Error("pets.loadPetsFromFS", "petType", pet.Id(), "path", path, "error", "duplicate pet type")
+ return
+ }
+
+ dst[pet.Id()] = &pet
+}
diff --git a/internal/pets/plugin_test.go b/internal/pets/plugin_test.go
new file mode 100644
index 000000000..d525303db
--- /dev/null
+++ b/internal/pets/plugin_test.go
@@ -0,0 +1,132 @@
+package pets
+
+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[string]string{}
+}
+
+func TestLoadPluginPets_MergesValidPet(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ yamlData := []byte("type: pluginwolf\n")
+ RegisterFS(newFakeFS(map[string][]byte{
+ `pets/pluginwolf.yaml`: yamlData,
+ }))
+
+ dst := map[string]*Pet{}
+ loadPluginPets(dst)
+
+ p, ok := dst["pluginwolf"]
+ if !ok {
+ t.Fatalf("expected pet pluginwolf to be loaded")
+ }
+ if p.Type != "pluginwolf" {
+ t.Fatalf("expected type %q, got %q", "pluginwolf", p.Type)
+ }
+}
+
+func TestLoadPluginPets_IgnoresWrongPrefix(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ RegisterFS(newFakeFS(map[string][]byte{
+ `mobs/pluginwolf.yaml`: []byte("type: pluginwolf\n"),
+ }))
+
+ dst := map[string]*Pet{}
+ loadPluginPets(dst)
+
+ if len(dst) != 0 {
+ t.Fatalf("expected wrong-prefix pet to be ignored, got %d", len(dst))
+ }
+}
+
+func TestLoadPluginPets_RejectsFilepathMismatch(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ RegisterFS(newFakeFS(map[string][]byte{
+ `pets/wrong.yaml`: []byte("type: pluginwolf\n"),
+ }))
+
+ dst := map[string]*Pet{}
+ loadPluginPets(dst)
+
+ if len(dst) != 0 {
+ t.Fatalf("expected mismatched-filepath pet to be skipped, got %d", len(dst))
+ }
+}
+
+func TestLoadPluginPets_DiskWinsOnDuplicate(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ RegisterFS(newFakeFS(map[string][]byte{
+ `pets/pluginwolf.yaml`: []byte("type: pluginwolf\nnamestyle: \":plugin\"\n"),
+ }))
+
+ dst := map[string]*Pet{
+ "pluginwolf": {Type: "pluginwolf", NameStyle: ":disk"},
+ }
+ loadPluginPets(dst)
+
+ if dst["pluginwolf"].NameStyle != ":disk" {
+ t.Fatalf("expected disk pet to win, got %q", dst["pluginwolf"].NameStyle)
+ }
+}
+
+func TestRegisterPetScript_ReturnedByGetScript(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ RegisterPetScript("pluginwolf", "onAct()")
+
+ p := &Pet{Type: "pluginwolf"}
+ if got := p.GetScript(); got != "onAct()" {
+ t.Fatalf("expected plugin script, got %q", got)
+ }
+ if !p.HasScript() {
+ t.Fatalf("expected HasScript to be true for plugin script")
+ }
+}
diff --git a/internal/quests/AGENTS.md b/internal/quests/AGENTS.md
index 7c1564e1f..a82b71cbd 100644
--- a/internal/quests/AGENTS.md
+++ b/internal/quests/AGENTS.md
@@ -9,6 +9,7 @@
- `quests.go`: quest structs, token parsing, progression checks, cache access, and bulk loading.
- `admin.go`: save/delete helpers that validate quest data and update the in-memory registry.
+- `plugin.go`: module data-file integration. `RegisterFS(...)` registers plugin filesystems; `loadPluginQuests` merges embedded `quests/*.yaml` into the registry inside `LoadDataFiles`. Disk quests win on duplicate ids.
## Working Rules
diff --git a/internal/quests/plugin.go b/internal/quests/plugin.go
new file mode 100644
index 000000000..2ed70a712
--- /dev/null
+++ b/internal/quests/plugin.go
@@ -0,0 +1,88 @@
+package quests
+
+import (
+ "io/fs"
+ "strings"
+
+ "github.com/GoMudEngine/GoMud/internal/fileloader"
+ "github.com/GoMudEngine/GoMud/internal/mudlog"
+ "gopkg.in/yaml.v2"
+)
+
+var (
+ pluginFileSystems []fileloader.ReadableGroupFS
+)
+
+// RegisterFS registers a plugin file system to be searched when loading quest
+// data files. Must be called before LoadDataFiles().
+func RegisterFS(f ...fileloader.ReadableGroupFS) {
+ pluginFileSystems = append(pluginFileSystems, f...)
+}
+
+// loadPluginQuests walks every sub-filesystem of every registered plugin FS,
+// reading quest YAML files from a "quests/" prefix and merging them into dst.
+// Disk-loaded quests take precedence: a plugin quest with a duplicate id is
+// logged and skipped.
+func loadPluginQuests(dst map[int]*Quest) {
+ for _, groupFS := range pluginFileSystems {
+ for subFS := range groupFS.AllFileSubSystems {
+ loadQuestsFromFS(subFS, dst)
+ }
+ }
+}
+
+func loadQuestsFromFS(subFS fs.ReadFileFS, dst map[int]*Quest) {
+ // PluginFiles does not support directory traversal, so use KnownPaths
+ // when available to enumerate files directly.
+ if pl, ok := subFS.(fileloader.PathLister); ok {
+ for _, path := range pl.KnownPaths() {
+ if !strings.HasPrefix(path, `quests/`) || !strings.HasSuffix(path, `.yaml`) {
+ continue
+ }
+ loadQuestFileFromFS(subFS, path, dst)
+ }
+ return
+ }
+
+ // Fallback: standard directory walk for FSes that support it.
+ _ = fs.WalkDir(subFS, `quests`, func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() || !strings.HasSuffix(path, `.yaml`) {
+ return nil
+ }
+ loadQuestFileFromFS(subFS, path, dst)
+ return nil
+ })
+}
+
+func loadQuestFileFromFS(subFS fs.ReadFileFS, path string, dst map[int]*Quest) {
+ b, err := subFS.ReadFile(path)
+ if err != nil {
+ mudlog.Error("quests.loadQuestsFromFS", "path", path, "error", err)
+ return
+ }
+
+ var quest Quest
+ if err := yaml.Unmarshal(b, &quest); err != nil {
+ mudlog.Error("quests.loadQuestsFromFS", "path", path, "error", err)
+ return
+ }
+
+ // Validate the Filepath() claim matches the actual path so the same
+ // rules as the disk loader apply.
+ if !strings.HasSuffix(path, quest.Filepath()) {
+ mudlog.Error("quests.loadQuestsFromFS", "path", path, "expected suffix", quest.Filepath(), "error", "filepath mismatch")
+ return
+ }
+
+ if err := quest.Validate(); err != nil {
+ mudlog.Error("quests.loadQuestsFromFS", "path", path, "error", err)
+ return
+ }
+
+ if _, exists := dst[quest.QuestId]; exists {
+ mudlog.Error("quests.loadQuestsFromFS", "questId", quest.QuestId, "path", path, "error", "duplicate quest id")
+ return
+ }
+
+ dst[quest.QuestId] = &quest
+}
diff --git a/internal/quests/plugin_test.go b/internal/quests/plugin_test.go
new file mode 100644
index 000000000..29c726f8f
--- /dev/null
+++ b/internal/quests/plugin_test.go
@@ -0,0 +1,151 @@
+package quests
+
+import (
+ "io/fs"
+ "os"
+ "testing"
+
+ "github.com/GoMudEngine/GoMud/internal/mudlog"
+)
+
+func TestMain(m *testing.M) {
+ mudlog.SetupLogger(nil, "", "", false)
+ os.Exit(m.Run())
+}
+
+// fakeFS is an in-memory ReadableGroupFS + PathLister used to exercise the
+// plugin loader without touching disk. It acts as a single sub-filesystem that
+// is also its own group.
+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)
+}
+
+// resetPluginState clears package-level plugin registration between tests.
+func resetPluginState() {
+ pluginFileSystems = nil
+}
+
+func TestLoadPluginQuests_MergesValidQuest(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ yamlData := []byte("questid: 9001\nname: Plugin Quest\nsteps:\n - id: start\n description: Begin\n")
+ RegisterFS(newFakeFS(map[string][]byte{
+ `quests/9001-plugin_quest.yaml`: yamlData,
+ }))
+
+ dst := map[int]*Quest{}
+ loadPluginQuests(dst)
+
+ q, ok := dst[9001]
+ if !ok {
+ t.Fatalf("expected quest 9001 to be loaded")
+ }
+ if q.Name != "Plugin Quest" {
+ t.Fatalf("expected name %q, got %q", "Plugin Quest", q.Name)
+ }
+}
+
+func TestLoadPluginQuests_IgnoresWrongPrefixAndExtension(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ RegisterFS(newFakeFS(map[string][]byte{
+ `items/9002-not-a-quest.yaml`: []byte("questid: 9002\nname: X\n"),
+ `quests/9003-readme.txt`: []byte("not yaml"),
+ `quests/9004-good.yaml`: []byte("questid: 9004\nname: Good\n"),
+ }))
+
+ dst := map[int]*Quest{}
+ loadPluginQuests(dst)
+
+ if _, ok := dst[9002]; ok {
+ t.Fatalf("wrong-prefix quest should be ignored")
+ }
+ if _, ok := dst[9004]; !ok {
+ t.Fatalf("expected quest 9004 to be loaded")
+ }
+ if len(dst) != 1 {
+ t.Fatalf("expected exactly 1 quest, got %d", len(dst))
+ }
+}
+
+func TestLoadPluginQuests_RejectsFilepathMismatch(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ // questid 9005 with name "Good" should be at quests/9005-good.yaml;
+ // here the filename does not match Filepath().
+ RegisterFS(newFakeFS(map[string][]byte{
+ `quests/wrong-name.yaml`: []byte("questid: 9005\nname: Good\n"),
+ }))
+
+ dst := map[int]*Quest{}
+ loadPluginQuests(dst)
+
+ if len(dst) != 0 {
+ t.Fatalf("expected mismatched-filepath quest to be skipped, got %d", len(dst))
+ }
+}
+
+func TestLoadPluginQuests_SkipsInvalid(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ // Empty name fails Validate(). Filepath for an empty name is "9006-.yaml".
+ RegisterFS(newFakeFS(map[string][]byte{
+ `quests/9006-.yaml`: []byte("questid: 9006\nname: \"\"\n"),
+ }))
+
+ dst := map[int]*Quest{}
+ loadPluginQuests(dst)
+
+ if len(dst) != 0 {
+ t.Fatalf("expected invalid quest to be skipped, got %d", len(dst))
+ }
+}
+
+func TestLoadPluginQuests_DiskWinsOnDuplicate(t *testing.T) {
+ resetPluginState()
+ defer resetPluginState()
+
+ RegisterFS(newFakeFS(map[string][]byte{
+ `quests/9007-plugin.yaml`: []byte("questid: 9007\nname: Plugin\n"),
+ }))
+
+ dst := map[int]*Quest{
+ 9007: {QuestId: 9007, Name: "Disk"},
+ }
+ loadPluginQuests(dst)
+
+ if dst[9007].Name != "Disk" {
+ t.Fatalf("expected disk quest to win, got %q", dst[9007].Name)
+ }
+}
diff --git a/internal/quests/quests.go b/internal/quests/quests.go
index 0cb256a92..129d13dac 100644
--- a/internal/quests/quests.go
+++ b/internal/quests/quests.go
@@ -194,6 +194,9 @@ func LoadDataFiles() {
quests = tmpQuests
+ // Merge quests from plugin file systems.
+ loadPluginQuests(quests)
+
mudlog.Info("quests.LoadDataFiles()", "loadedCount", len(quests), "Time Taken", time.Since(start))
}
diff --git a/main.go b/main.go
index 609a4fdb4..a6a69b3a6 100644
--- a/main.go
+++ b/main.go
@@ -20,6 +20,7 @@ import (
"github.com/GoMudEngine/GoMud/internal/colorpatterns"
"github.com/GoMudEngine/GoMud/internal/configs"
"github.com/GoMudEngine/GoMud/internal/connections"
+ "github.com/GoMudEngine/GoMud/internal/conversations"
"github.com/GoMudEngine/GoMud/internal/copyover"
"github.com/GoMudEngine/GoMud/internal/events"
"github.com/GoMudEngine/GoMud/internal/flags"
@@ -195,6 +196,11 @@ func main() {
templates.RegisterFS(plugins.GetPluginRegistry())
items.RegisterFS(plugins.GetPluginRegistry())
mutators.RegisterFS(plugins.GetPluginRegistry())
+ buffs.RegisterFS(plugins.GetPluginRegistry())
+ pets.RegisterFS(plugins.GetPluginRegistry())
+ quests.RegisterFS(plugins.GetPluginRegistry())
+ mobs.RegisterFS(plugins.GetPluginRegistry())
+ conversations.RegisterFS(plugins.GetPluginRegistry())
usercommands.AddFunctionExporter(plugins.GetPluginRegistry())
users.AddFunctionExporter(plugins.GetPluginRegistry())
usercommands.SetRoomTagProvider(plugins.GetRegisteredRoomTags)
diff --git a/modules/AGENTS.md b/modules/AGENTS.md
index 98e398efd..89015da36 100644
--- a/modules/AGENTS.md
+++ b/modules/AGENTS.md
@@ -11,6 +11,18 @@
- Module config belongs under `Modules..*` in `_datafiles/config.yaml`, and module code should read it via `plug.Config.Get(...)`.
- New modules are discovered through Go `init()` registration and wired through generation. After adding or removing modules, use the repo command flow that refreshes generated imports.
- If a module depends on room tags, reserve and document those tags explicitly. Room tags are the main opt-in integration point for module behavior.
+
+### Shipping data files from a module
+
+Modules can ship their own content data files (embedded via `AttachFileSystem`) for these systems, merged at load time after the on-disk data: `items`, `mutators`, `buffs`, `pets`, `quests`, `mobs`, `conversations`. Each core package exposes a `RegisterFS(...)` entrypoint wired up in `main.go` with `plugins.GetPluginRegistry()`.
+
+Rules that apply to all of them:
+
+- Embedded files live under a system-named prefix inside the module's `files/datafiles/` tree, e.g. `buffs/`, `pets/`, `quests/`, `mobs//`, `conversations//`.
+- The embedded path must end with the spec's `Filepath()` (the same invariant the disk loader enforces). For mobs this means `/-.yaml`.
+- Disk content wins on ID/key collisions; a duplicate plugin entry is logged and skipped (never fatal).
+- For systems with JS scripts (`buffs`, `pets`, `mobs`), embedded scripts are not on disk, so register them via the per-package helper: `buffs.RegisterBuffScript(buffId, src)`, `pets.RegisterPetScript(petType, src)`, `mobs.RegisterMobScript(mobId, tag, src)`. `GetScript()` checks these registries before the disk path.
+- Known limitation: embedded mob scripts are not enumerated by `GetAllScriptTags()`, so module mob scripts are not editable through the admin script-tag UI (same shape as embedded item scripts).
- Reuse the existing plugin hooks for:
- user or mob commands
- event listeners