From 018b265f3c4f2afe54492516f4a20c0461c4b7e9 Mon Sep 17 00:00:00 2001 From: Volte6 <143822+Volte6@users.noreply.github.com> Date: Wed, 6 May 2026 00:22:01 -0700 Subject: [PATCH] fast travel module --- .../world/default/rooms/frostfang/1067.yaml | 21 ++ .../world/default/rooms/frostfang/879.yaml | 6 +- .../world/default/rooms/mystarion/844.yaml | 3 + internal/mapper/mapper_test.go | 14 +- modules/all-modules.go | 1 + modules/fasttravel/fasttravel.go | 290 ++++++++++++++++++ .../files/data-overlays/config.yaml | 15 + .../files/data-overlays/keywords.yaml | 6 + .../html/admin/fasttravel-config.html | 238 ++++++++++++++ .../templates/help/fasttravel.template | 13 + 10 files changed, 596 insertions(+), 11 deletions(-) create mode 100755 _datafiles/world/default/rooms/frostfang/1067.yaml create mode 100644 modules/fasttravel/fasttravel.go create mode 100644 modules/fasttravel/files/data-overlays/config.yaml create mode 100644 modules/fasttravel/files/data-overlays/keywords.yaml create mode 100644 modules/fasttravel/files/datafiles/html/admin/fasttravel-config.html create mode 100644 modules/fasttravel/files/datafiles/templates/help/fasttravel.template diff --git a/_datafiles/world/default/rooms/frostfang/1067.yaml b/_datafiles/world/default/rooms/frostfang/1067.yaml new file mode 100755 index 000000000..2903e1d48 --- /dev/null +++ b/_datafiles/world/default/rooms/frostfang/1067.yaml @@ -0,0 +1,21 @@ +roomid: 1067 +zone: Frostfang +title: Magic Academy Back Room +description: The dusty back room of the Magic Academy is seldom visited, its air thick + with the quiet weight of forgotten knowledge and the faint scent of old parchment. + Shelves sag beneath the burden of neglected tomes and curious relics, their secrets + long undisturbed. At the heart of the chamber, a glowing dais hums with a steady, + almost breathing rhythm, its light spilling softly across the worn stone floor in + rippling patterns. Arcane sigils flicker and coil along its surface like living + things, whispering of distant places and unseen pathways. The very air around it + shimmers, charged with restrained energy. +biome: city +exits: + southeast: + roomid: 879 +tags: +- fast travel +mapx: -3 +mapy: -6 +mapz: 0 +hascoordinates: true diff --git a/_datafiles/world/default/rooms/frostfang/879.yaml b/_datafiles/world/default/rooms/frostfang/879.yaml index 8e44b1cea..60dd729bd 100755 --- a/_datafiles/world/default/rooms/frostfang/879.yaml +++ b/_datafiles/world/default/rooms/frostfang/879.yaml @@ -13,14 +13,12 @@ maplegend: Trainer exits: east: roomid: 5 + northwest: + roomid: 1067 spawninfo: - mobid: 50 message: A mage enters the room. respawnrate: 2 real minutes -skilltraining: - cast: - min: 1 - max: 4 mapx: -1 mapy: -4 mapz: 0 diff --git a/_datafiles/world/default/rooms/mystarion/844.yaml b/_datafiles/world/default/rooms/mystarion/844.yaml index 1ebd90d41..0df86d1cd 100755 --- a/_datafiles/world/default/rooms/mystarion/844.yaml +++ b/_datafiles/world/default/rooms/mystarion/844.yaml @@ -10,9 +10,12 @@ description: At the very end of Bloodroot Way, the shadows converge, and the air Shelves are lined with ancient, leather-bound tomes etched with glowing runes, alongside vials of mysterious, shimmering elixirs. Enchanted relics float in eerie stillness, their soft glow casting an otherworldly light. +biome: city exits: south: roomid: 843 +tags: +- fast travel mapx: 71 mapy: -4 mapz: 0 diff --git a/internal/mapper/mapper_test.go b/internal/mapper/mapper_test.go index 020e59e16..19ce84d22 100644 --- a/internal/mapper/mapper_test.go +++ b/internal/mapper/mapper_test.go @@ -52,13 +52,13 @@ func TestArrowForDelta(t *testing.T) { dx, dy, dz int want rune }{ - {0, -1, 0, '\u2502'}, // north: vertical bar - {0, 1, 0, '\u2502'}, // south: vertical bar - {-1, 0, 0, '\u2500'}, // west: horizontal bar - {1, 0, 0, '\u2500'}, // east: horizontal bar - {1, -1, 0, '\u2571'}, // northeast: slash - {-1, 1, 0, '\u2571'}, // southwest: slash - {1, 1, 0, '\u2572'}, // southeast: backslash + {0, -1, 0, '\u2502'}, // north: vertical bar + {0, 1, 0, '\u2502'}, // south: vertical bar + {-1, 0, 0, '\u2500'}, // west: horizontal bar + {1, 0, 0, '\u2500'}, // east: horizontal bar + {1, -1, 0, '\u2571'}, // northeast: slash + {-1, 1, 0, '\u2571'}, // southwest: slash + {1, 1, 0, '\u2572'}, // southeast: backslash {-1, -1, 0, '\u2572'}, // northwest: backslash {0, 0, 1, '^'}, // up {0, 0, -1, 'v'}, // down diff --git a/modules/all-modules.go b/modules/all-modules.go index bc8284556..c79eb6305 100644 --- a/modules/all-modules.go +++ b/modules/all-modules.go @@ -10,6 +10,7 @@ import ( _ "github.com/GoMudEngine/GoMud/modules/auctions" _ "github.com/GoMudEngine/GoMud/modules/cleanup" _ "github.com/GoMudEngine/GoMud/modules/clibridge" + _ "github.com/GoMudEngine/GoMud/modules/fasttravel" _ "github.com/GoMudEngine/GoMud/modules/follow" _ "github.com/GoMudEngine/GoMud/modules/gambling" _ "github.com/GoMudEngine/GoMud/modules/gmcp" diff --git a/modules/fasttravel/fasttravel.go b/modules/fasttravel/fasttravel.go new file mode 100644 index 000000000..7b5582dc2 --- /dev/null +++ b/modules/fasttravel/fasttravel.go @@ -0,0 +1,290 @@ +package fasttravel + +import ( + "embed" + "fmt" + "sort" + "strings" + + "github.com/GoMudEngine/GoMud/internal/events" + "github.com/GoMudEngine/GoMud/internal/items" + "github.com/GoMudEngine/GoMud/internal/plugins" + "github.com/GoMudEngine/GoMud/internal/rooms" + "github.com/GoMudEngine/GoMud/internal/term" + "github.com/GoMudEngine/GoMud/internal/users" + "github.com/GoMudEngine/GoMud/internal/util" +) + +var ( + //go:embed files/* + files embed.FS +) + +const ( + fastTravelTag = "fast travel" + dataKeyFmt = "fasttravel-user-%d" +) + +func init() { + m := &FastTravelModule{ + plug: plugins.New(`fasttravel`, `1.0`), + players: make(map[int]FastTravelData), + } + + if err := m.plug.AttachFileSystem(files); err != nil { + panic(err) + } + + m.plug.AddUserCommand(`fasttravel`, m.fastTravelCommand, false, false) + + m.plug.Web.AdminPage("Config", "fasttravel-config", "html/admin/fasttravel-config.html", true, "Modules", "Fast Travel", nil) + + m.plug.ReserveTags(fastTravelTag) + + m.plug.Callbacks.SetOnSave(m.onSave) + + events.RegisterListener(events.PlayerSpawn{}, m.onPlayerSpawn) + events.RegisterListener(events.PlayerDespawn{}, m.onPlayerDespawn) + + rooms.OnRoomLook.Register(m.onRoomLook) +} + +// FastTravelData holds the unlocked fast travel room IDs for a single user. +type FastTravelData struct { + UnlockedRoomIds []int `yaml:"unlockedroomids,omitempty"` +} + +// FastTravelModule owns all fast travel state. +type FastTravelModule struct { + plug *plugins.Plugin + players map[int]FastTravelData // keyed by userId; loaded on PlayerSpawn +} + +func dataKey(userId int) string { + return fmt.Sprintf(dataKeyFmt, userId) +} + +func (m *FastTravelModule) load(userId int) FastTravelData { + var data FastTravelData + m.plug.ReadIntoStruct(dataKey(userId), &data) + return data +} + +func (m *FastTravelModule) save(userId int, data FastTravelData) { + m.plug.WriteStruct(dataKey(userId), data) +} + +func (m *FastTravelModule) onSave() { + for userId, data := range m.players { + m.save(userId, data) + } +} + +func (m *FastTravelModule) onPlayerSpawn(e events.Event) events.ListenerReturn { + evt, ok := e.(events.PlayerSpawn) + if !ok { + return events.Continue + } + m.players[evt.UserId] = m.load(evt.UserId) + return events.Continue +} + +func (m *FastTravelModule) onPlayerDespawn(e events.Event) events.ListenerReturn { + evt, ok := e.(events.PlayerDespawn) + if !ok { + return events.Continue + } + if data, exists := m.players[evt.UserId]; exists { + m.save(evt.UserId, data) + delete(m.players, evt.UserId) + } + return events.Continue +} + +// onRoomLook injects a fast travel alert when the room has the fast travel tag. +func (m *FastTravelModule) onRoomLook(d rooms.RoomTemplateDetails) rooms.RoomTemplateDetails { + for _, t := range d.Tags { + if strings.EqualFold(t, fastTravelTag) { + d.RoomAlerts = append(d.RoomAlerts, + ` This is a fast travel station! fasttravel lists destinations.`, + ) + return d + } + } + return d +} + +// roomIsFastTravel returns true if the room has the fast travel tag. +func roomIsFastTravel(room *rooms.Room) bool { + return room.HasTag(fastTravelTag) +} + +// hasUnlocked returns true if the user has unlocked the given room. +func (m *FastTravelModule) hasUnlocked(userId, roomId int) bool { + data := m.players[userId] + for _, id := range data.UnlockedRoomIds { + if id == roomId { + return true + } + } + return false +} + +// unlock adds the roomId to the user's unlocked list if not already present. +// Returns true if the room was newly unlocked, false if it was already known. +func (m *FastTravelModule) unlock(userId, roomId int) bool { + if m.hasUnlocked(userId, roomId) { + return false + } + data := m.players[userId] + data.UnlockedRoomIds = append(data.UnlockedRoomIds, roomId) + m.players[userId] = data + return true +} + +// travelCost reads the configured gold cost and required item ID from the module config. +// A cost of 0 means no gold is required. An itemId of 0 means no item is required. +func (m *FastTravelModule) travelCost() (goldCost int, itemId int) { + if v, ok := m.plug.Config.Get(`GoldCost`).(int); ok && v > 0 { + goldCost = v + } + if v, ok := m.plug.Config.Get(`RequiredItemId`).(int); ok && v > 0 { + itemId = v + } + return goldCost, itemId +} + +// destEntry holds a resolved fast travel destination for display and matching. +type destEntry struct { + roomId int + title string +} + +func (m *FastTravelModule) fastTravelCommand(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) { + if !roomIsFastTravel(room) { + user.SendText(`You must be at a fast travel station to use fast travel.` + term.CRLFStr) + return true, nil + } + + // Always unlock the current room when the player uses the command here. + if m.unlock(user.UserId, room.RoomId) { + user.SendText(`You have discovered a new fast travel station: ` + room.Title + `!` + term.CRLFStr) + } + + // Build the list of unlocked destinations, excluding the current room. + data := m.players[user.UserId] + destinations := make([]destEntry, 0, len(data.UnlockedRoomIds)) + for _, id := range data.UnlockedRoomIds { + if id == room.RoomId { + continue + } + r := rooms.LoadRoom(id) + if r == nil { + continue + } + destinations = append(destinations, destEntry{roomId: id, title: r.Title}) + } + sort.Slice(destinations, func(i, j int) bool { + return destinations[i].title < destinations[j].title + }) + + // List mode. + if rest == `` { + if len(destinations) == 0 { + user.SendText(`You have not discovered any other fast travel stations yet.` + term.CRLFStr) + return true, nil + } + + var sb strings.Builder + sb.WriteString(`Fast Travel Destinations:` + "\n") + for i, dest := range destinations { + sb.WriteString(fmt.Sprintf(` %d) %s`+"\n", i+1, dest.title)) + } + user.SendText(sb.String()) + return true, nil + } + + // Travel mode: match the argument against destination titles. + titles := make([]string, len(destinations)) + for i, dest := range destinations { + titles[i] = dest.title + } + + closeMatch, exactMatch := util.FindMatchIn(rest, titles...) + + matchTitle := exactMatch + if matchTitle == `` { + matchTitle = closeMatch + } + if matchTitle == `` { + user.SendText(fmt.Sprintf(`No fast travel destination matching "%s" found.`+term.CRLFStr, rest)) + return true, nil + } + + var destRoomId int + for _, dest := range destinations { + if strings.EqualFold(dest.title, matchTitle) { + destRoomId = dest.roomId + break + } + } + if destRoomId == 0 { + user.SendText(fmt.Sprintf(`No fast travel destination matching "%s" found.`+term.CRLFStr, rest)) + return true, nil + } + + destRoom := rooms.LoadRoom(destRoomId) + if destRoom == nil { + user.SendText(`That fast travel destination is no longer available.` + term.CRLFStr) + return true, nil + } + + // Enforce travel cost before moving. + goldCost, requiredItemId := m.travelCost() + + if goldCost > 0 { + if user.Character.Gold < goldCost { + user.SendText(fmt.Sprintf(`You need at least %d gold to use the fast travel network.`+term.CRLFStr, goldCost)) + return true, nil + } + } + + var requiredItem items.Item + if requiredItemId > 0 { + itm, found := user.Character.FindInBackpack(fmt.Sprintf(`!%d`, requiredItemId)) + if !found { + iSpec := items.GetItemSpec(requiredItemId) + itemName := fmt.Sprintf(`item #%d`, requiredItemId) + if iSpec != nil { + itemName = iSpec.Name + } + user.SendText(fmt.Sprintf(`You need a %s to use the fast travel network.`+term.CRLFStr, itemName)) + return true, nil + } + requiredItem = itm + } + + // Deduct gold cost. + if goldCost > 0 { + user.Character.Gold -= goldCost + events.AddToQueue(events.EquipmentChange{ + UserId: user.UserId, + GoldChange: -goldCost, + }) + } + + // Consume required item. + if requiredItem.ItemId > 0 { + user.Character.RemoveItem(requiredItem) + events.AddToQueue(events.ItemOwnership{ + UserId: user.UserId, + Item: requiredItem, + Gained: false, + }) + } + + user.SendText(fmt.Sprintf(`You step through the fast travel network and arrive at %s.`+term.CRLFStr, destRoom.Title)) + rooms.MoveToRoom(user.UserId, destRoomId) + + return true, nil +} diff --git a/modules/fasttravel/files/data-overlays/config.yaml b/modules/fasttravel/files/data-overlays/config.yaml new file mode 100644 index 000000000..f49da8345 --- /dev/null +++ b/modules/fasttravel/files/data-overlays/config.yaml @@ -0,0 +1,15 @@ +################################################################################ +# If modified, these settings will be copied into your config overrides +# folder under `Modules.fasttravel`: +# +# Modules: +# fasttravel: +# GoldCost: 0 +# RequiredItemId: 0 +################################################################################ +# - GoldCost - +# Gold required each time a player uses fast travel. Set to 0 to disable. +GoldCost: 0 +# - RequiredItemId - +# Item ID that is consumed each time a player uses fast travel. Set to 0 to disable. +RequiredItemId: 0 diff --git a/modules/fasttravel/files/data-overlays/keywords.yaml b/modules/fasttravel/files/data-overlays/keywords.yaml new file mode 100644 index 000000000..58f390985 --- /dev/null +++ b/modules/fasttravel/files/data-overlays/keywords.yaml @@ -0,0 +1,6 @@ +help: + command: + travel: + - fasttravel +command-aliases: + 'fasttravel': ['ft'] diff --git a/modules/fasttravel/files/datafiles/html/admin/fasttravel-config.html b/modules/fasttravel/files/datafiles/html/admin/fasttravel-config.html new file mode 100644 index 000000000..d6fddc47e --- /dev/null +++ b/modules/fasttravel/files/datafiles/html/admin/fasttravel-config.html @@ -0,0 +1,238 @@ +{{template "header" .}} + + +

fasttravel - Module Config

+

Configuration keys for the fasttravel module. Click any value to edit it inline. Changes are staged until you apply them.

+ +
+ +
+

Pending Changes

+
+
+ + +
+
+ +
+ + + + + + +
KeyValue
Loading...
+
+ + + +{{template "footer" .}} diff --git a/modules/fasttravel/files/datafiles/templates/help/fasttravel.template b/modules/fasttravel/files/datafiles/templates/help/fasttravel.template new file mode 100644 index 000000000..edaeb24ec --- /dev/null +++ b/modules/fasttravel/files/datafiles/templates/help/fasttravel.template @@ -0,0 +1,13 @@ +Fast Travel + +Fast travel stations are special locations scattered across the world. +When you visit a fast travel station, it becomes permanently unlocked for you. +From any fast travel station, you can instantly travel to any other station +you have previously visited. + +Usage: + fasttravel - List available destinations + fasttravel - Travel to a destination (partial name match) + ft - Alias for fasttravel + +Travel may require gold or a specific item per use, depending on server configuration.