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
+
+
+
+
+
+
+
+
+
+
+
Key
Value
+
+
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.