-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
diff --git a/go.mod b/go.mod
index 56c7fa93..3ad427df 100644
--- a/go.mod
+++ b/go.mod
@@ -19,7 +19,7 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1
)
require (
diff --git a/internal/combat/combat.go b/internal/combat/combat.go
index cb7ddfb6..cf9ff24a 100644
--- a/internal/combat/combat.go
+++ b/internal/combat/combat.go
@@ -9,9 +9,9 @@ import (
"github.com/GoMudEngine/GoMud/internal/buffs"
"github.com/GoMudEngine/GoMud/internal/characters"
"github.com/GoMudEngine/GoMud/internal/configs"
+ "github.com/GoMudEngine/GoMud/internal/events"
"github.com/GoMudEngine/GoMud/internal/items"
"github.com/GoMudEngine/GoMud/internal/mobs"
- "github.com/GoMudEngine/GoMud/internal/mudlog"
"github.com/GoMudEngine/GoMud/internal/races"
"github.com/GoMudEngine/GoMud/internal/rooms"
"github.com/GoMudEngine/GoMud/internal/skills"
@@ -33,19 +33,69 @@ func AttackPlayerVsMob(user *users.UserRecord, mob *mobs.Mob) AttackResult {
attackResult := calculateCombat(*user.Character, mob.Character, User, Mob)
if attackResult.DamageToSource != 0 {
+ oldUserHealth := user.Character.Health
user.Character.ApplyHealthChange(attackResult.DamageToSource * -1)
+ newUserHealth := user.Character.Health
user.WimpyCheck()
+
+ if oldUserHealth != newUserHealth {
+ events.AddToQueue(events.CharacterVitalsChanged{
+ UserId: user.UserId,
+ })
+ }
}
+ oldHealth := mob.Character.Health
mob.Character.ApplyHealthChange(attackResult.DamageToTarget * -1)
+ newHealth := mob.Character.Health
+
+ if oldHealth != newHealth {
+ events.AddToQueue(events.MobVitalsChanged{
+ MobId: mob.InstanceId,
+ OldHealth: oldHealth,
+ NewHealth: newHealth,
+ OldMana: mob.Character.Mana,
+ NewMana: mob.Character.Mana,
+ ChangeType: "damage",
+ })
+ }
// Remember who has hit him
mob.Character.TrackPlayerDamage(user.UserId, attackResult.DamageToTarget)
if attackResult.Hit {
user.PlaySound(`hit-other`, `combat`)
+
+ if attackResult.DamageToTarget > 0 {
+ // Use priority 10 for damage events to ensure they're processed quickly
+ events.AddToQueue(events.DamageDealt{
+ SourceId: user.UserId,
+ SourceType: "player",
+ SourceName: user.Character.Name,
+ TargetId: mob.InstanceId,
+ TargetType: "mob",
+ TargetName: mob.Character.Name,
+ Amount: attackResult.DamageToTarget,
+ DamageType: "physical",
+ WeaponName: getWeaponName(user.Character),
+ SpellName: "",
+ IsCritical: attackResult.Crit,
+ IsKillingBlow: mob.Character.Health <= 0,
+ }, 10)
+ }
} else {
user.PlaySound(`miss`, `combat`)
+
+ events.AddToQueue(events.AttackAvoided{
+ AttackerId: user.UserId,
+ AttackerType: "player",
+ AttackerName: user.Character.Name,
+ DefenderId: mob.InstanceId,
+ DefenderType: "mob",
+ DefenderName: mob.Character.Name,
+ AvoidType: "miss",
+ WeaponName: getWeaponName(user.Character),
+ })
}
return attackResult
@@ -57,20 +107,64 @@ func AttackPlayerVsPlayer(userAtk *users.UserRecord, userDef *users.UserRecord)
attackResult := calculateCombat(*userAtk.Character, *userDef.Character, User, User)
if attackResult.DamageToSource != 0 {
+ oldAtkHealth := userAtk.Character.Health
userAtk.Character.ApplyHealthChange(attackResult.DamageToSource * -1)
+ newAtkHealth := userAtk.Character.Health
userAtk.WimpyCheck()
+
+ if oldAtkHealth != newAtkHealth {
+ events.AddToQueue(events.CharacterVitalsChanged{
+ UserId: userAtk.UserId,
+ })
+ }
}
if attackResult.DamageToTarget != 0 {
+ oldDefHealth := userDef.Character.Health
userDef.Character.ApplyHealthChange(attackResult.DamageToTarget * -1)
+ newDefHealth := userDef.Character.Health
userDef.WimpyCheck()
+
+ if oldDefHealth != newDefHealth {
+ events.AddToQueue(events.CharacterVitalsChanged{
+ UserId: userDef.UserId,
+ })
+ }
}
if attackResult.Hit {
userAtk.PlaySound(`hit-other`, `combat`)
userDef.PlaySound(`hit-self`, `combat`)
+
+ if attackResult.DamageToTarget > 0 {
+ events.AddToQueue(events.DamageDealt{
+ SourceId: userAtk.UserId,
+ SourceType: "player",
+ SourceName: userAtk.Character.Name,
+ TargetId: userDef.UserId,
+ TargetType: "player",
+ TargetName: userDef.Character.Name,
+ Amount: attackResult.DamageToTarget,
+ DamageType: "physical",
+ WeaponName: getWeaponName(userAtk.Character),
+ SpellName: "",
+ IsCritical: attackResult.Crit,
+ IsKillingBlow: userDef.Character.Health <= 0,
+ })
+ }
} else {
userAtk.PlaySound(`miss`, `combat`)
+
+ events.AddToQueue(events.AttackAvoided{
+ AttackerId: userAtk.UserId,
+ AttackerType: "player",
+ AttackerName: userAtk.Character.Name,
+ DefenderId: userDef.UserId,
+ DefenderType: "player",
+ DefenderName: userDef.Character.Name,
+ AvoidType: "miss",
+ WeaponName: getWeaponName(userAtk.Character),
+ })
}
return attackResult
@@ -81,15 +175,64 @@ func AttackMobVsPlayer(mob *mobs.Mob, user *users.UserRecord) AttackResult {
attackResult := calculateCombat(mob.Character, *user.Character, Mob, User)
+ oldHealth := mob.Character.Health
mob.Character.ApplyHealthChange(attackResult.DamageToSource * -1)
+ newHealth := mob.Character.Health
+
+ if oldHealth != newHealth {
+ events.AddToQueue(events.MobVitalsChanged{
+ MobId: mob.InstanceId,
+ OldHealth: oldHealth,
+ NewHealth: newHealth,
+ OldMana: mob.Character.Mana,
+ NewMana: mob.Character.Mana,
+ ChangeType: "damage",
+ })
+ }
if attackResult.DamageToTarget != 0 {
+ oldUserHealth := user.Character.Health
user.Character.ApplyHealthChange(attackResult.DamageToTarget * -1)
+ newUserHealth := user.Character.Health
user.WimpyCheck()
+
+ if oldUserHealth != newUserHealth {
+ events.AddToQueue(events.CharacterVitalsChanged{
+ UserId: user.UserId,
+ })
+ }
}
if attackResult.Hit {
user.PlaySound(`hit-self`, `combat`)
+
+ if attackResult.DamageToTarget > 0 {
+ events.AddToQueue(events.DamageDealt{
+ SourceId: mob.InstanceId,
+ SourceType: "mob",
+ SourceName: mob.Character.Name,
+ TargetId: user.UserId,
+ TargetType: "player",
+ TargetName: user.Character.Name,
+ Amount: attackResult.DamageToTarget,
+ DamageType: "physical",
+ WeaponName: getWeaponName(&mob.Character),
+ SpellName: "",
+ IsCritical: attackResult.Crit,
+ IsKillingBlow: user.Character.Health <= 0,
+ })
+ }
+ } else {
+ events.AddToQueue(events.AttackAvoided{
+ AttackerId: mob.InstanceId,
+ AttackerType: "mob",
+ AttackerName: mob.Character.Name,
+ DefenderId: user.UserId,
+ DefenderType: "player",
+ DefenderName: user.Character.Name,
+ AvoidType: "miss",
+ WeaponName: getWeaponName(&mob.Character),
+ })
}
return attackResult
@@ -98,10 +241,39 @@ func AttackMobVsPlayer(mob *mobs.Mob, user *users.UserRecord) AttackResult {
// Performs a combat round from a mob to a mob
func AttackMobVsMob(mobAtk *mobs.Mob, mobDef *mobs.Mob) AttackResult {
- attackResult := calculateCombat(mobAtk.Character, mobDef.Character, Mob, User)
+ attackResult := calculateCombat(mobAtk.Character, mobDef.Character, Mob, Mob)
+ // Track attacker health changes
+ oldHealthAtk := mobAtk.Character.Health
mobAtk.Character.ApplyHealthChange(attackResult.DamageToSource * -1)
+ newHealthAtk := mobAtk.Character.Health
+
+ if oldHealthAtk != newHealthAtk {
+ events.AddToQueue(events.MobVitalsChanged{
+ MobId: mobAtk.InstanceId,
+ OldHealth: oldHealthAtk,
+ NewHealth: newHealthAtk,
+ OldMana: mobAtk.Character.Mana,
+ NewMana: mobAtk.Character.Mana,
+ ChangeType: "damage",
+ })
+ }
+
+ // Track defender health changes
+ oldHealthDef := mobDef.Character.Health
mobDef.Character.ApplyHealthChange(attackResult.DamageToTarget * -1)
+ newHealthDef := mobDef.Character.Health
+
+ if oldHealthDef != newHealthDef {
+ events.AddToQueue(events.MobVitalsChanged{
+ MobId: mobDef.InstanceId,
+ OldHealth: oldHealthDef,
+ NewHealth: newHealthDef,
+ OldMana: mobDef.Character.Mana,
+ NewMana: mobDef.Character.Mana,
+ ChangeType: "damage",
+ })
+ }
// If attacking mob was player charmed, attribute damage done to that player
if charmedUserId := mobAtk.Character.GetCharmedUserId(); charmedUserId > 0 {
@@ -109,6 +281,36 @@ func AttackMobVsMob(mobAtk *mobs.Mob, mobDef *mobs.Mob) AttackResult {
mobDef.Character.TrackPlayerDamage(charmedUserId, attackResult.DamageToTarget)
}
+ if attackResult.Hit {
+ if attackResult.DamageToTarget > 0 {
+ events.AddToQueue(events.DamageDealt{
+ SourceId: mobAtk.InstanceId,
+ SourceType: "mob",
+ SourceName: mobAtk.Character.Name,
+ TargetId: mobDef.InstanceId,
+ TargetType: "mob",
+ TargetName: mobDef.Character.Name,
+ Amount: attackResult.DamageToTarget,
+ DamageType: "physical",
+ WeaponName: getWeaponName(&mobAtk.Character),
+ SpellName: "",
+ IsCritical: attackResult.Crit,
+ IsKillingBlow: mobDef.Character.Health <= 0,
+ })
+ }
+ } else {
+ events.AddToQueue(events.AttackAvoided{
+ AttackerId: mobAtk.InstanceId,
+ AttackerType: "mob",
+ AttackerName: mobAtk.Character.Name,
+ DefenderId: mobDef.InstanceId,
+ DefenderType: "mob",
+ DefenderName: mobDef.Character.Name,
+ AvoidType: "miss",
+ WeaponName: getWeaponName(&mobAtk.Character),
+ })
+ }
+
return attackResult
}
@@ -235,8 +437,6 @@ func calculateCombat(sourceChar characters.Character, targetChar characters.Char
for i := 0; i < attackCount; i++ {
- mudlog.Debug(`calculateCombat`, `Atk`, fmt.Sprintf(`%d/%d`, i+1, attackCount), `Source`, fmt.Sprintf(`%s (%s)`, sourceChar.Name, sourceType), `Target`, fmt.Sprintf(`%s (%s)`, targetChar.Name, targetType))
-
attackWeapons := []items.Item{}
dualWieldLevel := sourceChar.GetSkillLevel(skills.DualWield)
@@ -341,8 +541,6 @@ func calculateCombat(sourceChar characters.Character, targetChar characters.Char
msgSeed = weapon.ItemId
}
- mudlog.Debug("DiceRolls", "attacks", attacks, "dCount", dCount, "dSides", dSides, "dBonus", dBonus, "critBuffs", critBuffs)
-
// Individual weapons may get multiple attacks
for j := 0; j < attacks; j++ {
@@ -505,6 +703,7 @@ func calculateCombat(sourceChar characters.Character, targetChar characters.Char
attackResult.DamageToSource += attackSourceDamage
attackResult.DamageToSourceReduction += attackSourceReduction
+
}
if util.RollDice(1, 5) == 1 { // 20% chance to join
@@ -601,3 +800,15 @@ func Crits(sourceChar characters.Character, targetChar characters.Character) boo
return critRoll < critChance
}
+
+// Helper functions for combat events
+
+// getWeaponName safely gets the weapon name from a character
+func getWeaponName(char *characters.Character) string {
+ if char.Equipment.Weapon.ItemId > 0 {
+ return char.Equipment.Weapon.DisplayName()
+ }
+ // Return unarmed name from race
+ raceInfo := races.GetRace(char.RaceId)
+ return raceInfo.UnarmedName
+}
diff --git a/internal/combat/combat_events_test.go b/internal/combat/combat_events_test.go
new file mode 100644
index 00000000..b6fab318
--- /dev/null
+++ b/internal/combat/combat_events_test.go
@@ -0,0 +1,299 @@
+package combat_test
+
+import (
+ "testing"
+
+ "github.com/GoMudEngine/GoMud/internal/characters"
+ "github.com/GoMudEngine/GoMud/internal/combat"
+ "github.com/GoMudEngine/GoMud/internal/events"
+ "github.com/GoMudEngine/GoMud/internal/mobs"
+ "github.com/GoMudEngine/GoMud/internal/mudlog"
+ "github.com/GoMudEngine/GoMud/internal/stats"
+ "github.com/GoMudEngine/GoMud/internal/testhelpers"
+ "github.com/GoMudEngine/GoMud/internal/users"
+)
+
+func init() {
+ // Initialize logger for tests
+ mudlog.SetupLogger(nil, "HIGH", "", true)
+
+ // Initialize test races for combat testing
+ testhelpers.InitializeTestRaces()
+}
+
+// Helper function to create a properly initialized test user
+func createTestUser(id int, name string, health int, strength int) *users.UserRecord {
+ user := &users.UserRecord{
+ UserId: id,
+ Character: &characters.Character{
+ Name: name,
+ Health: health,
+ HealthMax: stats.StatInfo{Base: 100, Value: 100},
+ RaceId: 1, // Human race (assuming it exists)
+ Level: 1,
+ RoomId: 1,
+ },
+ }
+ user.Character.Stats.Strength.Base = strength
+ user.Character.Stats.Speed.Base = 10
+ user.Character.Stats.Smarts.Base = 10
+ user.Character.Stats.Vitality.Base = 10
+ user.Character.Stats.Mysticism.Base = 10
+ user.Character.Stats.Perception.Base = 10
+ // Initialize aggro to prevent nil pointer
+ user.Character.Aggro = &characters.Aggro{}
+ return user
+}
+
+// Helper function to create a properly initialized test mob
+func createTestMob(id int, name string, health int, strength int) *mobs.Mob {
+ mob := &mobs.Mob{
+ InstanceId: id,
+ MobId: 1,
+ Character: characters.Character{
+ Name: name,
+ Health: health,
+ HealthMax: stats.StatInfo{Base: 50, Value: 50},
+ RaceId: 1, // Human race (assuming it exists)
+ Level: 1,
+ RoomId: 1,
+ },
+ }
+ mob.Character.Stats.Strength.Base = strength
+ mob.Character.Stats.Speed.Base = 10
+ mob.Character.Stats.Smarts.Base = 10
+ mob.Character.Stats.Vitality.Base = 10
+ mob.Character.Stats.Mysticism.Base = 10
+ mob.Character.Stats.Perception.Base = 10
+ // Initialize aggro to prevent nil pointer
+ mob.Character.Aggro = &characters.Aggro{}
+ return mob
+}
+
+// TestDamageDealtEvent tests that DamageDealt events are fired correctly
+func TestDamageDealtEvent(t *testing.T) {
+ // Track events fired
+ eventsFired := make(map[string]bool)
+
+ // Register a test listener
+ events.RegisterListener(events.DamageDealt{}, func(e events.Event) events.ListenerReturn {
+ evt := e.(events.DamageDealt)
+ if evt.SourceId == 1 && evt.TargetId == 1 {
+ eventsFired["DamageDealt"] = true
+ }
+ return events.Continue
+ })
+
+ // Create test user and mob
+ user := createTestUser(1, "TestPlayer", 100, 10)
+ mob := createTestMob(1, "TestMob", 50, 5)
+
+ // Set up combat aggro
+ user.Character.SetAggro(0, 1, characters.DefaultAttack)
+ mob.Character.SetAggro(1, 0, characters.DefaultAttack)
+
+ // Simulate combat
+ attackResult := combat.AttackPlayerVsMob(user, mob)
+
+ // Check if attack hit and did damage
+ if attackResult.Hit && attackResult.DamageToTarget > 0 {
+ // Give events time to process
+ // In real code, events are processed asynchronously
+ // For testing, we'll just check if the flag was set
+ t.Log("Attack hit for", attackResult.DamageToTarget, "damage")
+ }
+}
+
+// TestMobVitalsChangedEvent tests that MobVitalsChanged events are fired
+func TestMobVitalsChangedEvent(t *testing.T) {
+ // Track events fired
+ eventsFired := make(map[string]bool)
+ var vitalsEvent events.MobVitalsChanged
+
+ // Register a test listener
+ events.RegisterListener(events.MobVitalsChanged{}, func(e events.Event) events.ListenerReturn {
+ evt := e.(events.MobVitalsChanged)
+ if evt.MobId == 1 {
+ eventsFired["MobVitalsChanged"] = true
+ vitalsEvent = evt
+ }
+ return events.Continue
+ })
+
+ // Create test user and mob
+ user := createTestUser(1, "TestPlayer", 100, 10)
+ mob := createTestMob(1, "TestMob", 50, 5)
+
+ // Set up combat aggro
+ user.Character.SetAggro(0, 1, characters.DefaultAttack)
+ mob.Character.SetAggro(1, 0, characters.DefaultAttack)
+
+ oldHealth := mob.Character.Health
+
+ // Simulate combat
+ attackResult := combat.AttackPlayerVsMob(user, mob)
+
+ // Check results
+ if attackResult.Hit && attackResult.DamageToTarget > 0 {
+ t.Log("Attack hit for", attackResult.DamageToTarget, "damage")
+ t.Log("Mob health changed from", oldHealth, "to", mob.Character.Health)
+
+ if eventsFired["MobVitalsChanged"] {
+ if vitalsEvent.OldHealth != oldHealth {
+ t.Errorf("Expected OldHealth %d, got %d", oldHealth, vitalsEvent.OldHealth)
+ }
+ if vitalsEvent.NewHealth != mob.Character.Health {
+ t.Errorf("Expected NewHealth %d, got %d", mob.Character.Health, vitalsEvent.NewHealth)
+ }
+ }
+ }
+}
+
+// TestAttackAvoidedEvent tests that AttackAvoided events are fired on misses
+func TestAttackAvoidedEvent(t *testing.T) {
+ // This test needs to run multiple times to ensure we get a miss
+ missFound := false
+ maxAttempts := 100 // Increase attempts to make miss more likely
+
+ for i := 0; i < maxAttempts && !missFound; i++ {
+ // Track events fired
+ eventsFired := make(map[string]bool)
+
+ // Register a test listener
+ events.RegisterListener(events.AttackAvoided{}, func(e events.Event) events.ListenerReturn {
+ evt := e.(events.AttackAvoided)
+ if evt.AttackerId == 1 {
+ eventsFired["AttackAvoided"] = true
+ missFound = true
+ }
+ return events.Continue
+ })
+
+ // Create test user with minimal stats for maximum miss chance
+ user := createTestUser(1, "TestPlayer", 100, 1) // Minimal strength
+ user.Character.Stats.Speed.Base = 1 // Minimal speed
+ user.Character.Stats.Perception.Base = 1 // Minimal perception
+
+ // Create mob with maximum defensive stats
+ mob := createTestMob(1, "TestMob", 50, 5)
+ mob.Character.Stats.Speed.Base = 50 // Very high speed for dodge
+ mob.Character.Stats.Perception.Base = 50 // Very high perception
+
+ // Set up combat aggro
+ user.Character.SetAggro(0, 1, characters.DefaultAttack)
+ mob.Character.SetAggro(1, 0, characters.DefaultAttack)
+
+ // Simulate combat
+ attackResult := combat.AttackPlayerVsMob(user, mob)
+
+ if !attackResult.Hit {
+ t.Logf("Attack missed on attempt %d!", i+1)
+
+ // Process any queued events immediately
+ events.ProcessEvents()
+
+ if !eventsFired["AttackAvoided"] {
+ t.Error("Expected AttackAvoided event to be fired on miss")
+ }
+ break
+ }
+ }
+
+ if !missFound {
+ t.Skip("No misses occurred in test runs - this may be a balance issue with combat calculations")
+ }
+}
+
+// TestPlayerVsPlayerCombatEvents tests events in PvP combat
+func TestPlayerVsPlayerCombatEvents(t *testing.T) {
+ // Track events fired
+ eventsFired := make(map[string]bool)
+
+ // Register test listeners
+ events.RegisterListener(events.DamageDealt{}, func(e events.Event) events.ListenerReturn {
+ evt := e.(events.DamageDealt)
+ if evt.SourceType == "player" && evt.TargetType == "player" {
+ eventsFired["PvPDamage"] = true
+ }
+ return events.Continue
+ })
+
+ // Create test users
+ attacker := createTestUser(1, "Attacker", 100, 10)
+ defender := createTestUser(2, "Defender", 100, 8)
+
+ // Set up PvP aggro
+ attacker.Character.SetAggro(2, 0, characters.DefaultAttack)
+ defender.Character.SetAggro(1, 0, characters.DefaultAttack)
+
+ // Simulate PvP combat
+ attackResult := combat.AttackPlayerVsPlayer(attacker, defender)
+
+ if attackResult.Hit && attackResult.DamageToTarget > 0 {
+ t.Log("PvP attack hit for", attackResult.DamageToTarget, "damage")
+ }
+}
+
+// TestMobVsMobCombatEvents tests events in mob vs mob combat
+func TestMobVsMobCombatEvents(t *testing.T) {
+ // Track events fired
+ mobVitalsCount := 0
+ damageDealtCount := 0
+
+ // Register test listeners
+ events.RegisterListener(events.MobVitalsChanged{}, func(e events.Event) events.ListenerReturn {
+ evt := e.(events.MobVitalsChanged)
+ t.Logf("MobVitalsChanged event: MobId=%d, OldHealth=%d, NewHealth=%d",
+ evt.MobId, evt.OldHealth, evt.NewHealth)
+ mobVitalsCount++
+ return events.Continue
+ })
+
+ events.RegisterListener(events.DamageDealt{}, func(e events.Event) events.ListenerReturn {
+ evt := e.(events.DamageDealt)
+ if evt.SourceType == "mob" && evt.TargetType == "mob" {
+ t.Logf("Mob vs Mob damage event: Damage=%d", evt.Amount)
+ damageDealtCount++
+ }
+ return events.Continue
+ })
+
+ // Try multiple times to ensure we get a hit
+ hitFound := false
+ for attempt := 0; attempt < 20 && !hitFound; attempt++ {
+ // Reset counters for each attempt
+ mobVitalsCount = 0
+ damageDealtCount = 0
+
+ // Create test mobs with better stats for more reliable hits
+ mobAtk := createTestMob(1, "AttackerMob", 50, 15) // Higher strength
+ mobDef := createTestMob(2, "DefenderMob", 50, 6)
+
+ // Set up mob vs mob aggro
+ mobAtk.Character.SetAggro(0, 2, characters.DefaultAttack)
+ mobDef.Character.SetAggro(0, 1, characters.DefaultAttack)
+
+ // Simulate mob vs mob combat
+ attackResult := combat.AttackMobVsMob(mobAtk, mobDef)
+
+ if attackResult.Hit && attackResult.DamageToTarget > 0 {
+ hitFound = true
+ t.Logf("Mob vs Mob attack hit for %d damage on attempt %d", attackResult.DamageToTarget, attempt+1)
+
+ // Process any queued events immediately
+ events.ProcessEvents()
+
+ if mobVitalsCount < 1 {
+ t.Errorf("Expected at least one MobVitalsChanged event, got %d", mobVitalsCount)
+ }
+ if damageDealtCount < 1 {
+ t.Errorf("Expected at least one DamageDealt event, got %d", damageDealtCount)
+ }
+ break
+ }
+ }
+
+ if !hitFound {
+ t.Skip("No hits occurred in test runs - this may be a balance issue with combat calculations")
+ }
+}
diff --git a/internal/configs/config.userinterface.go b/internal/configs/config.userinterface.go
new file mode 100644
index 00000000..de351df6
--- /dev/null
+++ b/internal/configs/config.userinterface.go
@@ -0,0 +1,72 @@
+package configs
+
+import (
+ "strings"
+)
+
+type UserInterface struct {
+ Formats UserInterfaceFormats `yaml:"Formats"`
+ Display UserInterfaceDisplay `yaml:"Display"`
+}
+
+type UserInterfaceFormats struct {
+ Prompt ConfigString `yaml:"Prompt"` // The in-game status prompt style
+ EnterRoomMessageWrapper ConfigString `yaml:"EnterRoomMessageWrapper"` // Special enter messages
+ ExitRoomMessageWrapper ConfigString `yaml:"ExitRoomMessageWrapper"` // Special exit messages
+ Time ConfigString `yaml:"Time"` // How to format time when displaying real time
+ TimeShort ConfigString `yaml:"TimeShort"` // How to format time when displaying real time (shortform)
+}
+
+type UserInterfaceDisplay struct {
+ ShowEmptyEquipmentSlots ConfigBool `yaml:"ShowEmptyEquipmentSlots"` // Whether to show empty equipment slots when looking at characters/mobs
+}
+
+func (u *UserInterface) Validate() {
+ u.Formats.Validate()
+ u.Display.Validate()
+}
+
+func (f *UserInterfaceFormats) Validate() {
+ if f.Prompt == `` {
+ f.Prompt = `{8}[{t} {T} {255}HP:{hp}{8}/{HP} {255}MP:{13}{mp}{8}/{13}{MP}{8}]{239}{h}{8}:`
+ }
+
+ // Must have a message wrapper...
+ if f.EnterRoomMessageWrapper == `` {
+ f.EnterRoomMessageWrapper = `%s` // default
+ }
+ if strings.LastIndex(string(f.EnterRoomMessageWrapper), `%s`) < 0 {
+ f.EnterRoomMessageWrapper += `%s` // append if missing
+ }
+
+ // Must have a message wrapper...
+ if f.ExitRoomMessageWrapper == `` {
+ f.ExitRoomMessageWrapper = `%s` // default
+ }
+ if strings.LastIndex(string(f.ExitRoomMessageWrapper), `%s`) < 0 {
+ f.ExitRoomMessageWrapper += `%s` // append if missing
+ }
+
+ if f.Time == `` {
+ f.Time = `Monday, 02-Jan-2006 03:04:05PM`
+ }
+
+ if f.TimeShort == `` {
+ f.TimeShort = `Jan 2 '06 3:04PM`
+ }
+}
+
+func (d *UserInterfaceDisplay) Validate() {
+ // ShowEmptyEquipmentSlots defaults to true (show all slots)
+ // The ConfigBool type handles the default value
+}
+
+// Convenience method to check if empty equipment slots should be shown
+func (c Config) ShouldShowEmptyEquipmentSlots() bool {
+ return bool(c.UserInterface.Display.ShowEmptyEquipmentSlots)
+}
+
+// GetUserInterfaceConfig returns the UserInterface configuration
+func GetUserInterfaceConfig() UserInterface {
+ return GetConfig().UserInterface
+}
diff --git a/internal/configs/config_types.go b/internal/configs/config_types.go
index f2be9510..3fb702ac 100644
--- a/internal/configs/config_types.go
+++ b/internal/configs/config_types.go
@@ -21,15 +21,15 @@ type ConfigValue interface {
func StringToConfigValue(strVal string, typeName string) ConfigValue {
switch typeName {
- case "configs.ConfigInt":
+ case "configs.ConfigInt", "int":
v := ConfigInt(0)
v.Set(strVal)
return &v
- case "configs.ConfigUInt64":
+ case "configs.ConfigUInt64", "uint64":
var v ConfigUInt64 = 0
v.Set(strVal)
return &v
- case "configs.ConfigString":
+ case "configs.ConfigString", "string":
var v ConfigString = ""
v.Set(strVal)
return &v
@@ -37,15 +37,15 @@ func StringToConfigValue(strVal string, typeName string) ConfigValue {
var v ConfigSecret = ""
v.Set(strVal)
return &v
- case "configs.ConfigFloat":
+ case "configs.ConfigFloat", "float64", "float32":
var v ConfigFloat = 0
v.Set(strVal)
return &v
- case "configs.ConfigBool":
+ case "configs.ConfigBool", "bool":
var v ConfigBool = false
v.Set(strVal)
return &v
- case "configs.ConfigSliceString":
+ case "configs.ConfigSliceString", "[]string":
var v ConfigSliceString = []string{}
v.Set(strVal)
return &v
@@ -71,7 +71,9 @@ func StringToConfigValue(strVal string, typeName string) ConfigValue {
return &v
}
- var v ConfigSliceString = []string{}
+ // Default to string instead of ConfigSliceString for unknown types
+ // This is safer for module configs which are typically plain strings
+ var v ConfigString = ""
v.Set(strVal)
return &v
}
diff --git a/internal/configs/configs.go b/internal/configs/configs.go
index 5018a06a..4ed08c8d 100644
--- a/internal/configs/configs.go
+++ b/internal/configs/configs.go
@@ -11,6 +11,7 @@ import (
"github.com/GoMudEngine/GoMud/internal/mudlog"
"github.com/GoMudEngine/GoMud/internal/util"
"gopkg.in/yaml.v2"
+ yamlv3 "gopkg.in/yaml.v3"
)
const (
@@ -34,20 +35,20 @@ var (
type Config struct {
// Start config subsections
- Server Server `yaml:"Server"`
- Memory Memory `yaml:"Memory"`
- LootGoblin LootGoblin `yaml:"LootGoblin"`
- Timing Timing `yaml:"Timing"`
- FilePaths FilePaths `yaml:"FilePaths"`
- GamePlay GamePlay `yaml:"GamePlay"`
- Integrations Integrations `yaml:"Integrations"`
- TextFormats TextFormats `yaml:"TextFormats"`
- Translation Translation `yaml:"Translation"`
- Network Network `yaml:"Network"`
- Scripting Scripting `yaml:"Scripting"`
- SpecialRooms SpecialRooms `yaml:"SpecialRooms"`
- Validation Validation `yaml:"Validation"`
- Roles Roles `yaml:"Roles"`
+ Server Server `yaml:"Server"`
+ Memory Memory `yaml:"Memory"`
+ LootGoblin LootGoblin `yaml:"LootGoblin"`
+ Timing Timing `yaml:"Timing"`
+ FilePaths FilePaths `yaml:"FilePaths"`
+ GamePlay GamePlay `yaml:"GamePlay"`
+ UserInterface UserInterface `yaml:"UserInterface"`
+ Integrations Integrations `yaml:"Integrations"`
+ Translation Translation `yaml:"Translation"`
+ Network Network `yaml:"Network"`
+ Scripting Scripting `yaml:"Scripting"`
+ SpecialRooms SpecialRooms `yaml:"SpecialRooms"`
+ Validation Validation `yaml:"Validation"`
+ Roles Roles `yaml:"Roles"`
// Plugins is a special case
Modules Modules `yaml:"Modules"`
@@ -89,6 +90,56 @@ func AddOverlayOverrides(dotMap map[string]any) error {
return configData.OverlayOverrides(dotMap)
}
+// AddOverlayDefaults only adds values that don't already exist in overrides
+// This is used by plugins to provide default values without overwriting user settings
+func AddOverlayDefaults(dotMap map[string]any) error {
+ configDataLock.RLock()
+ defer configDataLock.RUnlock()
+
+ // Create a map of only new values to add
+ newValues := make(map[string]any)
+
+ // Get the current Modules config to check what's already set
+ currentModules := configData.Modules
+ flatCurrent := Flatten(map[string]any{"Modules": currentModules})
+
+ // Also flatten the overrides map for comparison since it might be nested
+ flatOverrides := Flatten(overrides)
+
+ for k, v := range dotMap {
+ // Check if this key already exists in the current config or overrides
+ // The key k is like "Modules.gmcp.mapper_url"
+ if _, exists := flatCurrent[k]; !exists {
+ // Check the flattened overrides map
+ if _, exists := flatOverrides[k]; !exists {
+ if strings.Index(k, `.`) != -1 {
+ parts := strings.Split(k, `.`)
+
+ for i := len(parts) - 1; i >= 0; i-- {
+ tmpKey := strings.Join(parts[i:], `.`)
+ keyLookups[strings.ToLower(tmpKey)] = k
+
+ tmpKey = strings.Join(parts[i:], ``)
+ keyLookups[strings.ToLower(tmpKey)] = k
+ }
+ } else {
+ keyLookups[strings.ToLower(k)] = k
+ }
+
+ typeLookups[k] = reflect.TypeOf(v).String()
+ overrides[k] = v
+ newValues[k] = v
+ }
+ }
+ }
+
+ // Only overlay the new values
+ if len(newValues) > 0 {
+ return configData.OverlayOverrides(newValues)
+ }
+ return nil
+}
+
// OverlayDotMap overlays values from a dot-syntax map onto the Config.
func (c *Config) OverlayOverrides(dotMap map[string]any) error {
// First unflatten the dot map into a nested map.
@@ -287,6 +338,143 @@ func (c Config) AllConfigData(excludeStrings ...string) map[string]any {
return finalOutput
}
+// marshalConfigWithOrder preserves the original file structure when updating config values
+func marshalConfigWithOrder(config map[string]any) ([]byte, error) {
+ // For backward compatibility, if there's no existing file to preserve structure from,
+ // just use the standard marshal
+ overridePath := overridePath()
+ existingBytes, err := os.ReadFile(util.FilePath(overridePath))
+ if err != nil {
+ // File doesn't exist yet, use standard marshal
+ return yaml.Marshal(config)
+ }
+
+ // Parse the existing file to preserve its structure
+ var rootNode yamlv3.Node
+ if err := yamlv3.Unmarshal(existingBytes, &rootNode); err != nil {
+ // If we can't parse it with v3, fall back to standard marshal
+ return yaml.Marshal(config)
+ }
+
+ // Update the node tree with new values from config map
+ updateNodeFromMap(&rootNode, config)
+
+ // Marshal back with preserved structure
+ return yamlv3.Marshal(&rootNode)
+}
+
+// updateNodeFromMap recursively updates a yaml.Node tree with values from a map
+// while preserving the node structure, comments, and order
+func updateNodeFromMap(node *yamlv3.Node, updates map[string]any) {
+ if node.Kind != yamlv3.DocumentNode {
+ return
+ }
+
+ // Process the document content
+ if len(node.Content) > 0 {
+ updateNodeContent(node.Content[0], updates)
+ }
+}
+
+// updateNodeContent handles updating the content of a node
+func updateNodeContent(node *yamlv3.Node, updates map[string]any) {
+ if node.Kind != yamlv3.MappingNode {
+ return
+ }
+
+ // Track which keys we've seen in the existing structure
+ seenKeys := make(map[string]bool)
+ newContent := make([]*yamlv3.Node, 0, len(node.Content))
+
+ // Update existing keys while preserving order
+ for i := 0; i < len(node.Content); i += 2 {
+ keyNode := node.Content[i]
+ valueNode := node.Content[i+1]
+
+ if keyNode.Kind == yamlv3.ScalarNode {
+ key := keyNode.Value
+
+ // Check if this key should still exist
+ if newValue, exists := updates[key]; exists {
+ seenKeys[key] = true
+ updateValueNode(valueNode, newValue)
+ newContent = append(newContent, keyNode, valueNode)
+ }
+ // If key doesn't exist in updates, it gets removed by not adding to newContent
+ }
+ }
+
+ // Add new keys that weren't in the original structure
+ for key, value := range updates {
+ if !seenKeys[key] {
+ // Add new key-value pair at the end
+ keyNode := &yamlv3.Node{
+ Kind: yamlv3.ScalarNode,
+ Value: key,
+ }
+ valueNode := &yamlv3.Node{}
+ setNodeValue(valueNode, value)
+
+ newContent = append(newContent, keyNode, valueNode)
+ }
+ }
+
+ node.Content = newContent
+}
+
+// updateValueNode updates a value node with new data
+func updateValueNode(node *yamlv3.Node, value any) {
+ switch v := value.(type) {
+ case map[string]any:
+ if node.Kind == yamlv3.MappingNode {
+ // Recursively update map nodes
+ updateNodeContent(node, v)
+ } else {
+ // Type changed, replace the node
+ setNodeValue(node, value)
+ }
+ case []any:
+ // Replace array values entirely (for simplicity)
+ setNodeValue(node, value)
+ default:
+ // Update scalar value
+ if node.Kind == yamlv3.ScalarNode {
+ node.Value = fmt.Sprintf("%v", value)
+ } else {
+ setNodeValue(node, value)
+ }
+ }
+}
+
+// setNodeValue sets a node to represent a value
+func setNodeValue(node *yamlv3.Node, value any) {
+ switch v := value.(type) {
+ case map[string]any:
+ node.Kind = yamlv3.MappingNode
+ node.Content = make([]*yamlv3.Node, 0, len(v)*2)
+ for k, val := range v {
+ keyNode := &yamlv3.Node{
+ Kind: yamlv3.ScalarNode,
+ Value: k,
+ }
+ valueNode := &yamlv3.Node{}
+ setNodeValue(valueNode, val)
+ node.Content = append(node.Content, keyNode, valueNode)
+ }
+ case []any:
+ node.Kind = yamlv3.SequenceNode
+ node.Content = make([]*yamlv3.Node, 0, len(v))
+ for _, item := range v {
+ itemNode := &yamlv3.Node{}
+ setNodeValue(itemNode, item)
+ node.Content = append(node.Content, itemNode)
+ }
+ default:
+ node.Kind = yamlv3.ScalarNode
+ node.Value = fmt.Sprintf("%v", value)
+ }
+}
+
func SetVal(propertyPath string, newVal string) error {
propertyPath, propertyType := FindFullPath(propertyPath)
@@ -306,8 +494,8 @@ func SetVal(propertyPath string, newVal string) error {
overrides = unflattenMap(flatOverrides)
- // save the new config.
- writeBytes, err := yaml.Marshal(overrides)
+ // save the new config with structure preservation
+ writeBytes, err := marshalConfigWithOrder(overrides)
if err != nil {
return err
}
diff --git a/internal/events/const.go b/internal/events/const.go
index 8050f383..836afa56 100644
--- a/internal/events/const.go
+++ b/internal/events/const.go
@@ -24,4 +24,5 @@ const (
CmdBlockInput EventFlag = 0b00001000 // This command when started sets user input to blocking all commands that don't AllowWhenDowned.
CmdUnBlockInput EventFlag = 0b00010000 // When this command finishes, it will make sure user input is not blocked.
CmdBlockInputUntilComplete EventFlag = CmdBlockInput | CmdUnBlockInput // SHortcut to include both in one command
+ CmdNoRoomGMCP EventFlag = 0b00100000 // Skip GMCP room updates for this command (used to prevent duplicate sends)
)
diff --git a/internal/events/eventtypes.go b/internal/events/eventtypes.go
index 62053b94..8a6275ac 100644
--- a/internal/events/eventtypes.go
+++ b/internal/events/eventtypes.go
@@ -333,6 +333,14 @@ type CharacterVitalsChanged struct {
func (p CharacterVitalsChanged) Type() string { return `CharacterVitalsChanged` }
+type CharacterAlignmentChanged struct {
+ UserId int
+ OldAlignment int
+ NewAlignment int
+}
+
+func (p CharacterAlignmentChanged) Type() string { return `CharacterAlignmentChanged` }
+
// Health, mana, etc.
type CharacterTrained struct {
UserId int
@@ -384,3 +392,236 @@ type RedrawPrompt struct {
func (l RedrawPrompt) Type() string { return `RedrawPrompt` }
func (l RedrawPrompt) UniqueID() string { return `RedrawPrompt-` + strconv.Itoa(l.UserId) }
+
+// Combat State Management Events
+
+// CombatStarted fires when combat begins between entities
+type CombatStarted struct {
+ AttackerId int
+ AttackerType string // "player" or "mob"
+ AttackerName string
+ DefenderId int
+ DefenderType string // "player" or "mob"
+ DefenderName string
+ RoomId int
+ InitiatedBy string // command/action that started combat
+}
+
+func (c CombatStarted) Type() string { return `CombatStarted` }
+
+// CombatEnded fires when combat ends (not due to death)
+type CombatEnded struct {
+ EntityId int
+ EntityType string // "player" or "mob"
+ EntityName string
+ Reason string // "fled", "broke", "peace", "distance", etc.
+ RoomId int
+ Duration int // Combat duration in seconds
+}
+
+func (c CombatEnded) Type() string { return `CombatEnded` }
+
+// Damage and Healing Tracking Events
+
+// DamageDealt fires immediately after damage calculation and application
+type DamageDealt struct {
+ SourceId int
+ SourceType string // "player" or "mob"
+ SourceName string
+ TargetId int
+ TargetType string // "player" or "mob"
+ TargetName string
+ Amount int
+ DamageType string // "physical", "magical", "fire", etc.
+ WeaponName string // Name of weapon used (if applicable)
+ SpellName string // Name of spell used (if applicable)
+ IsCritical bool
+ IsKillingBlow bool
+}
+
+func (d DamageDealt) Type() string { return `DamageDealt` }
+
+// HealingReceived fires whenever health is restored
+type HealingReceived struct {
+ SourceId int
+ SourceType string // "player", "mob", "item", "regen"
+ SourceName string
+ TargetId int
+ TargetType string // "player" or "mob"
+ TargetName string
+ Amount int
+ HealType string // "spell", "potion", "regen", "item", etc.
+ SpellName string // Name of healing spell (if applicable)
+ IsOverheal bool
+}
+
+func (h HealingReceived) Type() string { return `HealingReceived` }
+
+// Target and Aggro Management Events
+
+// TargetChanged fires when a combatant's primary target changes
+type TargetChanged struct {
+ EntityId int
+ EntityType string // "player" or "mob"
+ EntityName string
+ OldTargetId int
+ OldTargetType string
+ OldTargetName string
+ NewTargetId int
+ NewTargetType string
+ NewTargetName string
+ Reason string // "manual", "death", "fled", "taunt", etc.
+}
+
+func (t TargetChanged) Type() string { return `TargetChanged` }
+
+// AggroGained fires when a mob becomes hostile to someone
+type AggroGained struct {
+ MobId int
+ MobName string
+ TargetId int
+ TargetType string // "player" or "mob"
+ TargetName string
+ IsInitial bool // True if this is the first aggro
+ ThreatLevel int
+}
+
+func (a AggroGained) Type() string { return `AggroGained` }
+
+// AggroLost fires when a mob stops being hostile
+type AggroLost struct {
+ MobId int
+ MobName string
+ TargetId int
+ TargetType string // "player" or "mob"
+ TargetName string
+ Reason string // "death", "distance", "reset", "peace", etc.
+}
+
+func (a AggroLost) Type() string { return `AggroLost` }
+
+// Mob-Specific Events
+
+// MobVitalsChanged fires whenever a mob's health or mana changes
+type MobVitalsChanged struct {
+ MobId int
+ OldHealth int
+ NewHealth int
+ OldMana int
+ NewMana int
+ ChangeType string // "damage", "heal", "regen", etc.
+}
+
+func (m MobVitalsChanged) Type() string { return `MobVitalsChanged` }
+
+// MobStatusChanged fires when status effects are applied/removed from mobs
+type MobStatusChanged struct {
+ MobId int
+ Status string // "stunned", "blinded", "slowed", etc.
+ Added bool // True if added, false if removed
+ Duration int // Duration in seconds (0 for permanent)
+ SourceId int // Who applied the status
+}
+
+func (m MobStatusChanged) Type() string { return `MobStatusChanged` }
+
+// Combat Action Events
+
+// CombatActionStarted fires when combat actions begin (casting, channeling, etc.)
+type CombatActionStarted struct {
+ EntityId int
+ EntityType string // "player" or "mob"
+ EntityName string
+ Action string // "spell", "ability", "item", etc.
+ ActionName string
+ TargetId int
+ TargetName string
+ CastTime float64 // Time to complete in seconds
+ Interruptible bool
+}
+
+func (c CombatActionStarted) Type() string { return `CombatActionStarted` }
+
+// CombatActionCompleted fires when an action finishes (successfully or not)
+type CombatActionCompleted struct {
+ EntityId int
+ EntityType string // "player" or "mob"
+ EntityName string
+ Action string
+ ActionName string
+ Success bool
+ FailureReason string
+}
+
+func (c CombatActionCompleted) Type() string { return `CombatActionCompleted` }
+
+// CombatActionInterrupted fires when an action is interrupted before completion
+type CombatActionInterrupted struct {
+ EntityId int
+ EntityType string // "player" or "mob"
+ EntityName string
+ Action string
+ ActionName string
+ InterruptedById int
+ InterruptedByType string // Type of interrupter
+ InterruptedByName string
+ InterruptType string // "damage", "stun", "silence", etc.
+}
+
+func (c CombatActionInterrupted) Type() string { return `CombatActionInterrupted` }
+
+// Defense and Avoidance Events
+
+// AttackAvoided fires when an attack fails to connect
+type AttackAvoided struct {
+ AttackerId int
+ AttackerType string // "player" or "mob"
+ AttackerName string
+ DefenderId int
+ DefenderType string // "player" or "mob"
+ DefenderName string
+ AvoidType string // "miss", "dodge", "parry", "block"
+ WeaponName string
+}
+
+func (a AttackAvoided) Type() string { return `AttackAvoided` }
+
+// Special Combat Events
+
+// CombatEffectTriggered fires when DoTs, bleeds, or other effects tick
+type CombatEffectTriggered struct {
+ SourceId int
+ SourceName string
+ TargetId int
+ TargetType string // "player" or "mob"
+ TargetName string
+ Effect string // "bleed", "poison", "burn", etc.
+ Damage int
+ TicksRemaining int
+}
+
+func (c CombatEffectTriggered) Type() string { return `CombatEffectTriggered` }
+
+// CombatantFled fires when someone attempts to flee
+type CombatantFled struct {
+ EntityId int
+ EntityType string // "player" or "mob"
+ EntityName string
+ Direction string
+ Success bool
+ PreventedBy string // What prevented it (if failed)
+}
+
+func (c CombatantFled) Type() string { return `CombatantFled` }
+
+// Room Events
+
+// ExitLockChanged fires when an exit's lock state changes
+type ExitLockChanged struct {
+ Event
+ RoomId int
+ ExitName string
+ Locked bool // true if now locked, false if now unlocked
+}
+
+func (e ExitLockChanged) Type() string { return `ExitLockChanged` }
diff --git a/internal/hooks/CombatEvents_Logger.go b/internal/hooks/CombatEvents_Logger.go
new file mode 100644
index 00000000..f42739bf
--- /dev/null
+++ b/internal/hooks/CombatEvents_Logger.go
@@ -0,0 +1,56 @@
+package hooks
+
+import (
+ "time"
+
+ "github.com/GoMudEngine/GoMud/internal/events"
+ "github.com/GoMudEngine/GoMud/internal/mudlog"
+)
+
+// This file contains event listeners that log combat events for debugging and verification purposes.
+// Since the freezing issue is NOT related to combat events, these are safe to use.
+
+func init() {
+ // TESTING: Defer listener registration to avoid potential init-time lock issues
+ // This delays registration by 5 seconds to let the game fully initialize
+ go func() {
+ time.Sleep(5 * time.Second) // Let game fully initialize
+
+ mudlog.Info("CombatEvents", "action", "Registering combat event listeners")
+
+ // Register listeners after delay
+ events.RegisterListener(events.DamageDealt{}, logDamageDealt)
+ events.RegisterListener(events.AttackAvoided{}, logAttackAvoided)
+
+ mudlog.Info("CombatEvents", "action", "Combat event listeners registered")
+ }()
+}
+
+func logDamageDealt(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.DamageDealt)
+ if !ok {
+ return events.Continue
+ }
+
+ mudlog.Debug("DamageDealt",
+ "SourceType", evt.SourceType,
+ "TargetType", evt.TargetType,
+ "Amount", evt.Amount,
+ "IsCritical", evt.IsCritical)
+
+ return events.Continue
+}
+
+func logAttackAvoided(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.AttackAvoided)
+ if !ok {
+ return events.Continue
+ }
+
+ mudlog.Debug("AttackAvoided",
+ "AttackerType", evt.AttackerType,
+ "DefenderType", evt.DefenderType,
+ "AvoidType", evt.AvoidType)
+
+ return events.Continue
+}
diff --git a/internal/hooks/PlayerDespawn_HandleLeave.go b/internal/hooks/PlayerDespawn_HandleLeave.go
index a894e5c4..590e4224 100644
--- a/internal/hooks/PlayerDespawn_HandleLeave.go
+++ b/internal/hooks/PlayerDespawn_HandleLeave.go
@@ -13,6 +13,7 @@ import (
"github.com/GoMudEngine/GoMud/internal/rooms"
"github.com/GoMudEngine/GoMud/internal/templates"
"github.com/GoMudEngine/GoMud/internal/users"
+ "github.com/GoMudEngine/GoMud/modules/gmcp"
)
//
@@ -65,6 +66,10 @@ func HandleLeave(e events.Event) events.ListenerReturn {
if err := users.LogOutUserByConnectionId(connId); err != nil {
mudlog.Error("Log Out Error", "connectionId", connId, "error", err)
}
+
+ // Clean up all GMCP state for this user
+ gmcp.CleanupUser(evt.UserId)
+
connections.Remove(connId)
specialRooms := configs.GetSpecialRoomsConfig()
diff --git a/internal/items/attack_messages.go b/internal/items/attack_messages.go
index 1427ef5c..27bbdd7c 100644
--- a/internal/items/attack_messages.go
+++ b/internal/items/attack_messages.go
@@ -118,6 +118,10 @@ func GetAttackMessage(subType ItemSubType, pctDamage int) AttackOptions {
return attackMsgOptions
}
}
- // default to generic.
+ // default to generic, but prevent infinite recursion
+ if subType == Generic {
+ // Return empty attack options to prevent crash
+ return AttackOptions{}
+ }
return GetAttackMessage(Generic, pctDamage)
}
diff --git a/internal/mapper/mapper.go b/internal/mapper/mapper.go
index 044d5d50..e13911b0 100644
--- a/internal/mapper/mapper.go
+++ b/internal/mapper/mapper.go
@@ -227,6 +227,22 @@ func (r *mapper) RootRoomId() int {
return r.rootRoomId
}
+// GetLowestRoomId returns the lowest room ID in the connected map area
+// This provides a stable identifier for the map regardless of which room was used to create it
+func (r *mapper) GetLowestRoomId() int {
+ if len(r.crawledRooms) == 0 {
+ return r.rootRoomId
+ }
+
+ lowestId := 0
+ for roomId := range r.crawledRooms {
+ if lowestId == 0 || roomId < lowestId {
+ lowestId = roomId
+ }
+ }
+ return lowestId
+}
+
func (r *mapper) CrawledRoomIds() []int {
roomIds := []int{}
for roomId := range r.crawledRooms {
@@ -998,15 +1014,15 @@ func PreCacheMaps() {
func validateRoomBiomes() {
missingBiomeCount := 0
invalidBiomeCount := 0
-
+
for _, roomId := range rooms.GetAllRoomIds() {
room := rooms.LoadRoom(roomId)
if room == nil {
continue
}
-
+
originalBiome := room.Biome
-
+
// Check if room has no biome
if originalBiome == "" {
zoneBiome := rooms.GetZoneBiome(room.Zone)
@@ -1022,7 +1038,7 @@ func validateRoomBiomes() {
}
}
}
-
+
if missingBiomeCount > 0 || invalidBiomeCount > 0 {
mudlog.Info("Biome validation complete", "missing", missingBiomeCount, "invalid", invalidBiomeCount)
}
diff --git a/internal/mobcommands/attack.go b/internal/mobcommands/attack.go
index 4004a532..096da8b2 100644
--- a/internal/mobcommands/attack.go
+++ b/internal/mobcommands/attack.go
@@ -6,6 +6,7 @@ import (
"github.com/GoMudEngine/GoMud/internal/buffs"
"github.com/GoMudEngine/GoMud/internal/characters"
+ "github.com/GoMudEngine/GoMud/internal/events"
"github.com/GoMudEngine/GoMud/internal/mobs"
"github.com/GoMudEngine/GoMud/internal/rooms"
"github.com/GoMudEngine/GoMud/internal/users"
@@ -115,6 +116,18 @@ func Attack(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) {
mob.Character.SetAggro(attackPlayerId, 0, characters.DefaultAttack)
+ // Fire combat event for GMCP
+ events.AddToQueue(events.CombatStarted{
+ AttackerId: mob.InstanceId,
+ AttackerType: "mob",
+ AttackerName: mob.Character.Name,
+ DefenderId: attackPlayerId,
+ DefenderType: "player",
+ DefenderName: u.Character.Name,
+ RoomId: room.RoomId,
+ InitiatedBy: "attack",
+ })
+
if !isSneaking {
u.SendText(fmt.Sprintf(`
%s prepares to fight you!`, mob.Character.Name))
diff --git a/internal/mobcommands/go.go b/internal/mobcommands/go.go
index 231b6ec0..c3aadc92 100644
--- a/internal/mobcommands/go.go
+++ b/internal/mobcommands/go.go
@@ -30,7 +30,7 @@ func Go(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) {
}
if !foundRoomExit {
- c := configs.GetTextFormatsConfig()
+ c := configs.GetUserInterfaceConfig().Formats
if forceRoomId == room.RoomId {
return true, nil
@@ -112,7 +112,7 @@ func Go(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) {
room.RemoveMob(mob.InstanceId)
destRoom.AddMob(mob.InstanceId)
- c := configs.GetTextFormatsConfig()
+ c := configs.GetUserInterfaceConfig().Formats
// Tell the old room they are leaving
room.SendText(
diff --git a/internal/mobcommands/suicide.go b/internal/mobcommands/suicide.go
index 820ab2dd..4a964035 100644
--- a/internal/mobcommands/suicide.go
+++ b/internal/mobcommands/suicide.go
@@ -36,8 +36,6 @@ func Suicide(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) {
// Useful to know sometimes
mobs.TrackRecentDeath(mob.InstanceId)
- mudlog.Debug(`Mob Death`, `name`, mob.Character.Name, `rest`, rest)
-
// Make sure to clean up any charm stuff if it's being removed
if charmedUserId := mob.Character.RemoveCharm(); charmedUserId > 0 {
if charmedUser := users.GetByUserId(charmedUserId); charmedUser != nil {
@@ -147,8 +145,6 @@ func Suicide(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) {
finalXPVal := int(math.Ceil(float64(xpVal) * xpScaler))
- mudlog.Debug("XP Calculation", "MobLevel", mob.Character.Level, "XPBase", mobXP, "xpVal", xpVal, "xpVariation", xpVariation, "xpScaler", xpScaler, "finalXPVal", finalXPVal)
-
user.GrantXP(finalXPVal, `combat`)
// Apply alignment changes
@@ -157,13 +153,17 @@ func Suicide(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) {
user.Character.UpdateAlignment(alignmentAdj)
alignmentAfter := user.Character.AlignmentName()
- mudlog.Debug("Alignment", "user Alignment", user.Character.Alignment, "mob Alignment", mob.Character.Alignment, `alignmentAdj`, alignmentAdj, `alignmentBefore`, alignmentBefore, `alignmentAfter`, alignmentAfter)
-
if alignmentBefore != alignmentAfter {
alignmentBefore = fmt.Sprintf(`
%s`, alignmentBefore, alignmentBefore)
alignmentAfter = fmt.Sprintf(`
%s`, alignmentAfter, alignmentAfter)
updateTxt := fmt.Sprintf(`
Your alignment has shifted from %s to %s!`, alignmentBefore, alignmentAfter)
user.SendText(updateTxt)
+
+ events.AddToQueue(events.CharacterAlignmentChanged{
+ UserId: user.UserId,
+ OldAlignment: int(user.Character.Alignment) - alignmentAdj,
+ NewAlignment: int(user.Character.Alignment),
+ })
}
// Chance to learn to tame the creature.
@@ -180,8 +180,6 @@ func Suicide(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) {
targetNumber = 1
}
- mudlog.Debug("Tame Chance", "levelDelta", levelDelta, "skillsDelta", skillsDelta, "targetNumber", targetNumber)
-
if util.Rand(1000) < targetNumber {
if mob.IsTameable() && user.Character.GetSkillLevel(skills.Tame) > 0 {
@@ -237,13 +235,17 @@ func Suicide(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) {
user.Character.UpdateAlignment(alignmentAdj)
alignmentAfter := user.Character.AlignmentName()
- mudlog.Debug("Alignment", "user Alignment", user.Character.Alignment, "mob Alignment", mob.Character.Alignment, `alignmentAdj`, alignmentAdj, `alignmentBefore`, alignmentBefore, `alignmentAfter`, alignmentAfter)
-
if alignmentBefore != alignmentAfter {
alignmentBefore = fmt.Sprintf(`
%s`, alignmentBefore, alignmentBefore)
alignmentAfter = fmt.Sprintf(`
%s`, alignmentAfter, alignmentAfter)
updateTxt := fmt.Sprintf(`
Your alignment has shifted from %s to %s!`, alignmentBefore, alignmentAfter)
user.SendText(updateTxt)
+
+ events.AddToQueue(events.CharacterAlignmentChanged{
+ UserId: user.UserId,
+ OldAlignment: int(user.Character.Alignment) - alignmentAdj,
+ NewAlignment: int(user.Character.Alignment),
+ })
}
// Chance to learn to tame the creature.
@@ -260,8 +262,6 @@ func Suicide(rest string, mob *mobs.Mob, room *rooms.Room) (bool, error) {
targetNumber = 1
}
- mudlog.Debug("Tame Chance", "levelDelta", levelDelta, "skillsDelta", skillsDelta, "targetNumber", targetNumber)
-
if util.Rand(1000) < targetNumber {
if mob.IsTameable() && user.Character.GetSkillLevel(skills.Tame) > 0 {
diff --git a/internal/plugins/plugins.go b/internal/plugins/plugins.go
index ea99800c..a44e7f5c 100644
--- a/internal/plugins/plugins.go
+++ b/internal/plugins/plugins.go
@@ -464,7 +464,9 @@ func Load(dataFilesPath string) {
for k, v := range dataMap {
overlayMap[fmt.Sprintf(`Modules.%s.%s`, p.name, k)] = v
}
- configs.AddOverlayOverrides(overlayMap)
+ // Use AddOverlayDefaults to only add values that don't already exist
+ // This ensures user overrides from config-overrides.yaml take precedence
+ configs.AddOverlayDefaults(overlayMap)
}
}
diff --git a/internal/races/races.go b/internal/races/races.go
index 0dc7b20a..aae0f5b8 100644
--- a/internal/races/races.go
+++ b/internal/races/races.go
@@ -91,9 +91,11 @@ func (r *Race) Validate() error {
return errors.New("race has no description")
}
if r.Size == "" {
- return errors.New("race has no size")
+ mudlog.Error("Race validation", "raceId", r.RaceId, "name", r.Name, "issue", fmt.Sprintf("Race has no size defined, defaulting to medium. Add 'size: medium' (or small/large) to the race file"))
+ r.Size = Medium
+ } else {
+ r.Size = Size(strings.ToLower(string(r.Size))) // Sometimes a mismatching CaSe value is provided.
}
- r.Size = Size(strings.ToLower(string(r.Size))) // Sometimes a mismatching CaSe value is provided.
// Recalculate stats, based on level one because this is actually the baseline for the race
r.Stats.Strength.Recalculate(1)
@@ -194,3 +196,18 @@ func LoadDataFiles() {
mudlog.Info("races.LoadDataFiles()", "loadedCount", len(races), "Time Taken", time.Since(start))
}
+
+// SetRaceForTesting allows test code to inject race data directly.
+// This should only be used in test code.
+func SetRaceForTesting(race *Race) {
+ if races == nil {
+ races = make(map[int]*Race)
+ }
+ races[race.RaceId] = race
+}
+
+// ClearRacesForTesting removes all races from the map.
+// This should only be used in test cleanup.
+func ClearRacesForTesting() {
+ races = make(map[int]*Race)
+}
diff --git a/internal/rooms/rooms.go b/internal/rooms/rooms.go
index 49906960..83c5bc3f 100644
--- a/internal/rooms/rooms.go
+++ b/internal/rooms/rooms.go
@@ -803,6 +803,9 @@ func (r *Room) RemoveMob(mobInstanceId int) {
r.MarkVisited(mobInstanceId, VisitorMob, 1)
+ // Clean up any combat involving this mob before removing it
+ r.RemoveMobFromCombatPool(mobInstanceId)
+
mobLen := len(r.mobs)
for i := 0; i < mobLen; i++ {
if r.mobs[i] == mobInstanceId {
@@ -858,6 +861,12 @@ func (r *Room) SetExitLock(exitName string, locked bool) {
}
}
+ // Fire event for exit lock state change
+ events.AddToQueue(events.ExitLockChanged{
+ RoomId: r.RoomId,
+ ExitName: exitName,
+ Locked: locked,
+ })
}
func (r *Room) GetExitInfo(exitName string) (exitInfo exit.RoomExit, ok bool) {
@@ -2198,7 +2207,6 @@ func (r *Room) Validate() error {
}
}
-
// Make sure all items are validated (and have uids)
for i := range r.Items {
r.Items[i].Validate()
@@ -2309,3 +2317,28 @@ func (r *Room) CanPvp(attUser *users.UserRecord, defUser *users.UserRecord) erro
return nil
}
+
+// RemoveMobFromCombatPool removes a mob from all combat in the room
+// This clears aggro for any users or mobs that were targeting this mob
+func (r *Room) RemoveMobFromCombatPool(mobInstanceId int) {
+ // Clear aggro for any players in the room targeting this mob
+ for _, userId := range r.players {
+ if user := users.GetByUserId(userId); user != nil {
+ if user.Character.Aggro != nil && user.Character.Aggro.MobInstanceId == mobInstanceId {
+ user.Character.Aggro = nil
+ }
+ }
+ }
+
+ // Clear aggro for any mobs in the room targeting this mob
+ for _, mId := range r.mobs {
+ if mId == mobInstanceId {
+ continue // Skip the mob being removed
+ }
+ if mob := mobs.GetInstance(mId); mob != nil {
+ if mob.Character.Aggro != nil && mob.Character.Aggro.MobInstanceId == mobInstanceId {
+ mob.Character.Aggro = nil
+ }
+ }
+ }
+}
diff --git a/internal/testhelpers/races.go b/internal/testhelpers/races.go
new file mode 100644
index 00000000..24601b1f
--- /dev/null
+++ b/internal/testhelpers/races.go
@@ -0,0 +1,116 @@
+// Package testhelpers provides utilities for testing GoMud components
+package testhelpers
+
+import (
+ "github.com/GoMudEngine/GoMud/internal/items"
+ "github.com/GoMudEngine/GoMud/internal/races"
+ "github.com/GoMudEngine/GoMud/internal/stats"
+)
+
+// InitializeTestRaces sets up minimal race data for testing combat and other systems
+// that depend on race information. This should be called in test init() functions.
+func InitializeTestRaces() {
+ // Create a basic human race for testing
+ human := &races.Race{
+ RaceId: 1,
+ Name: "Human",
+ Description: "A standard human for testing",
+ DefaultAlignment: 0,
+ Size: races.Medium,
+ TNLScale: 1.0,
+ UnarmedName: "fist",
+ Tameable: false,
+ Damage: items.Damage{
+ Attacks: 1,
+ DiceRoll: "1d4",
+ DiceCount: 1,
+ SideCount: 4,
+ BonusDamage: 0,
+ },
+ Selectable: true,
+ KnowsFirstAid: true,
+ Stats: stats.Statistics{
+ Strength: stats.StatInfo{Base: 10},
+ Speed: stats.StatInfo{Base: 10},
+ Smarts: stats.StatInfo{Base: 10},
+ Vitality: stats.StatInfo{Base: 10},
+ Mysticism: stats.StatInfo{Base: 10},
+ Perception: stats.StatInfo{Base: 10},
+ },
+ }
+
+ // Create a basic orc race for variety in testing
+ orc := &races.Race{
+ RaceId: 2,
+ Name: "Orc",
+ Description: "A standard orc for testing",
+ DefaultAlignment: -20,
+ Size: races.Medium,
+ TNLScale: 1.1,
+ UnarmedName: "claw",
+ Tameable: false,
+ Damage: items.Damage{
+ Attacks: 1,
+ DiceRoll: "1d6",
+ DiceCount: 1,
+ SideCount: 6,
+ BonusDamage: 1,
+ },
+ Selectable: false,
+ KnowsFirstAid: false,
+ Stats: stats.Statistics{
+ Strength: stats.StatInfo{Base: 12},
+ Speed: stats.StatInfo{Base: 8},
+ Smarts: stats.StatInfo{Base: 6},
+ Vitality: stats.StatInfo{Base: 12},
+ Mysticism: stats.StatInfo{Base: 4},
+ Perception: stats.StatInfo{Base: 8},
+ },
+ }
+
+ // Add a small creature for testing size differences
+ mouse := &races.Race{
+ RaceId: 3,
+ Name: "Mouse",
+ Description: "A small mouse for testing",
+ DefaultAlignment: 0,
+ Size: races.Small,
+ TNLScale: 0.5,
+ UnarmedName: "bite",
+ Tameable: true,
+ Damage: items.Damage{
+ Attacks: 1,
+ DiceRoll: "1d2",
+ DiceCount: 1,
+ SideCount: 2,
+ BonusDamage: 0,
+ },
+ Selectable: false,
+ KnowsFirstAid: false,
+ Stats: stats.Statistics{
+ Strength: stats.StatInfo{Base: 2},
+ Speed: stats.StatInfo{Base: 15},
+ Smarts: stats.StatInfo{Base: 3},
+ Vitality: stats.StatInfo{Base: 3},
+ Mysticism: stats.StatInfo{Base: 1},
+ Perception: stats.StatInfo{Base: 12},
+ },
+ }
+
+ // Use the AddRaceForTesting function to add races to the internal map
+ AddRaceForTesting(human)
+ AddRaceForTesting(orc)
+ AddRaceForTesting(mouse)
+}
+
+// AddRaceForTesting adds a race directly to the races map for testing purposes.
+// This bypasses the normal file loading mechanism.
+func AddRaceForTesting(race *races.Race) {
+ races.SetRaceForTesting(race)
+}
+
+// CleanupTestRaces removes all test races from the races map.
+// This should be called in test cleanup to avoid polluting other tests.
+func CleanupTestRaces() {
+ races.ClearRacesForTesting()
+}
diff --git a/internal/usercommands/attack.go b/internal/usercommands/attack.go
index 55e008e1..2a80f4c4 100644
--- a/internal/usercommands/attack.go
+++ b/internal/usercommands/attack.go
@@ -183,6 +183,18 @@ func Attack(rest string, user *users.UserRecord, room *rooms.Room, flags events.
user.Character.SetAggro(0, attackMobInstanceId, characters.DefaultAttack)
+ // Immediately fire combat events for GMCP
+ events.AddToQueue(events.CombatStarted{
+ AttackerId: user.UserId,
+ AttackerType: "player",
+ AttackerName: user.Character.Name,
+ DefenderId: attackMobInstanceId,
+ DefenderType: "mob",
+ DefenderName: m.Character.Name,
+ RoomId: room.RoomId,
+ InitiatedBy: "attack",
+ })
+
user.SendText(
fmt.Sprintf(`You prepare to enter into mortal combat with
%s.`, m.Character.Name),
)
@@ -239,6 +251,18 @@ func Attack(rest string, user *users.UserRecord, room *rooms.Room, flags events.
user.Character.SetAggro(attackPlayerId, 0, characters.DefaultAttack)
+ // Immediately fire combat events for GMCP
+ events.AddToQueue(events.CombatStarted{
+ AttackerId: user.UserId,
+ AttackerType: "player",
+ AttackerName: user.Character.Name,
+ DefenderId: attackPlayerId,
+ DefenderType: "player",
+ DefenderName: p.Character.Name,
+ RoomId: room.RoomId,
+ InitiatedBy: "attack",
+ })
+
user.SendText(
fmt.Sprintf(`You prepare to enter into mortal combat with
%s.`, p.Character.Name),
)
diff --git a/internal/usercommands/break.go b/internal/usercommands/break.go
index 51384499..3ad3e1c4 100644
--- a/internal/usercommands/break.go
+++ b/internal/usercommands/break.go
@@ -12,6 +12,12 @@ func Break(rest string, user *users.UserRecord, room *rooms.Room, flags events.E
if user.Character.Aggro != nil {
user.Character.Aggro = nil
+ // Fire CombatEnded event for GMCP tracking
+ events.AddToQueue(events.CombatEnded{
+ EntityId: user.UserId,
+ EntityType: "player",
+ Reason: "break",
+ })
user.SendText(`You break off combat.`)
room.SendText(
fmt.Sprintf(`
%s breaks off combat.`, user.Character.Name),
diff --git a/internal/usercommands/go.go b/internal/usercommands/go.go
index 5a8c1834..300d7433 100644
--- a/internal/usercommands/go.go
+++ b/internal/usercommands/go.go
@@ -29,7 +29,7 @@ func Go(rest string, user *users.UserRecord, room *rooms.Room, flags events.Even
return true, nil
}
- c := configs.GetTextFormatsConfig()
+ c := configs.GetUserInterfaceConfig().Formats
isSneaking := user.Character.HasBuffFlag(buffs.Hidden)
@@ -141,7 +141,7 @@ func Go(rest string, user *users.UserRecord, room *rooms.Room, flags events.Even
// Send GMCP message
if f, ok := GetExportedFunction(`SendGMCPEvent`); ok {
if gmcpSendFunc, ok := f.(func(int, string, any)); ok { // make sure the func definition is `func(int, string, any)`
- gmcpSendFunc(user.UserId, `Room.WrongDir`, fmt.Sprintf(`"%s"`, exitName))
+ gmcpSendFunc(user.UserId, `Room.Wrongdir`, map[string]string{"dir": exitName})
}
}
@@ -348,7 +348,7 @@ func Go(rest string, user *users.UserRecord, room *rooms.Room, flags events.Even
handled = true
if doLook, err := scripting.TryRoomScriptEvent(`onEnter`, user.UserId, destRoom.RoomId); err != nil || doLook {
- Look(``, user, destRoom, events.CmdSecretly)
+ Look(``, user, destRoom, events.CmdSecretly|events.CmdNoRoomGMCP)
}
room.PlaySound(`room-exit`, `movement`, user.UserId)
@@ -366,7 +366,7 @@ func Go(rest string, user *users.UserRecord, room *rooms.Room, flags events.Even
// Send GMCP message
if f, ok := GetExportedFunction(`SendGMCPEvent`); ok {
if gmcpSendFunc, ok := f.(func(int, string, any)); ok { // make sure the func definition is `func(int, string, any)`
- gmcpSendFunc(user.UserId, `Room.WrongDir`, fmt.Sprintf(`"%s"`, rest))
+ gmcpSendFunc(user.UserId, `Room.Wrongdir`, map[string]string{"dir": rest})
}
}
diff --git a/internal/usercommands/history.go b/internal/usercommands/history.go
index 1a6e4a03..afccf2f1 100644
--- a/internal/usercommands/history.go
+++ b/internal/usercommands/history.go
@@ -21,7 +21,7 @@ func History(rest string, user *users.UserRecord, room *rooms.Room, flags events
`
%s`,
}
- tFormat := string(configs.GetTextFormatsConfig().TimeShort)
+ tFormat := string(configs.GetUserInterfaceConfig().Formats.TimeShort)
for itm := range user.EventLog.Items {
diff --git a/internal/usercommands/look.go b/internal/usercommands/look.go
index 05d86ac6..ffa36cab 100644
--- a/internal/usercommands/look.go
+++ b/internal/usercommands/look.go
@@ -20,6 +20,7 @@ import (
func Look(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) {
secretLook := flags.Has(events.CmdSecretly)
+ suppressGMCP := flags.Has(events.CmdNoRoomGMCP)
visibility := room.GetVisibility()
@@ -65,7 +66,7 @@ func Look(rest string, user *users.UserRecord, room *rooms.Room, flags events.Ev
// Make it a "secret looks" now because we don't want another look message sent out by the lookRoom() func
secretLook = true
}
- lookRoom(user, room.RoomId, secretLook || isSneaking)
+ lookRoom(user, room.RoomId, secretLook || isSneaking, suppressGMCP)
return true, nil
}
@@ -264,7 +265,7 @@ func Look(rest string, user *users.UserRecord, room *rooms.Room, flags events.Ev
room.SendText(fmt.Sprintf(`
%s peers toward the %s.`, user.Character.Name, exitName), user.UserId)
}
- lookRoom(user, lookRoomId, secretLook || isSneaking)
+ lookRoom(user, lookRoomId, secretLook || isSneaking, suppressGMCP)
return true, nil
@@ -423,7 +424,7 @@ func Look(rest string, user *users.UserRecord, room *rooms.Room, flags events.Ev
}
-func lookRoom(user *users.UserRecord, roomId int, secretLook bool) {
+func lookRoom(user *users.UserRecord, roomId int, secretLook bool, suppressGMCP bool) {
room := rooms.LoadRoom(roomId)
@@ -603,4 +604,13 @@ func lookRoom(user *users.UserRecord, roomId int, secretLook bool) {
textOut, _ = templates.Process("descriptions/exits", details, user.UserId)
user.SendText(textOut)
+ // Send full GMCP room update when user looks (unless suppressed)
+ if !suppressGMCP {
+ if f, ok := GetExportedFunction(`TriggerRoomUpdate`); ok {
+ if triggerRoomUpdate, ok := f.(func(int)); ok {
+ triggerRoomUpdate(user.UserId)
+ }
+ }
+ }
+
}
diff --git a/internal/usercommands/set.go b/internal/usercommands/set.go
index 6bfee604..fac62c1e 100644
--- a/internal/usercommands/set.go
+++ b/internal/usercommands/set.go
@@ -15,7 +15,7 @@ import (
func Set(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) {
args := util.SplitButRespectQuotes(strings.ToLower(rest))
- c := configs.GetTextFormatsConfig()
+ c := configs.GetUserInterfaceConfig().Formats
if len(args) == 0 {
diff --git a/internal/usercommands/start.go b/internal/usercommands/start.go
index f99c43da..93135a18 100644
--- a/internal/usercommands/start.go
+++ b/internal/usercommands/start.go
@@ -211,7 +211,7 @@ func Start(rest string, user *users.UserRecord, room *rooms.Room, flags events.E
// Tell the new room they have arrived
destRoom.SendText(
- fmt.Sprintf(configs.GetTextFormatsConfig().EnterRoomMessageWrapper.String(),
+ fmt.Sprintf(configs.GetUserInterfaceConfig().Formats.EnterRoomMessageWrapper.String(),
fmt.Sprintf(`
%s enters from
somewhere.`, user.Character.Name),
),
user.UserId,
diff --git a/internal/usercommands/usercommands.go b/internal/usercommands/usercommands.go
index b9de6e0c..4e044451 100644
--- a/internal/usercommands/usercommands.go
+++ b/internal/usercommands/usercommands.go
@@ -440,7 +440,7 @@ func TryRoomScripts(input, alias, rest string, userId int) (bool, error) {
// Send GMCP message for script-blocked direction
if f, ok := GetExportedFunction(`SendGMCPEvent`); ok {
if gmcpSendFunc, ok := f.(func(int, string, any)); ok { // make sure the func definition is `func(int, string, any)`
- gmcpSendFunc(user.UserId, `Room.WrongDir`, fmt.Sprintf(`"%s"`, alias))
+ gmcpSendFunc(user.UserId, `Room.Wrongdir`, map[string]string{"dir": alias})
}
}
diff --git a/internal/users/inbox.go b/internal/users/inbox.go
index d23e018c..753df06c 100644
--- a/internal/users/inbox.go
+++ b/internal/users/inbox.go
@@ -57,6 +57,6 @@ func (i *Inbox) Empty() {
}
func (m Message) DateString() string {
- tFormat := string(configs.GetConfig().TextFormats.Time)
+ tFormat := string(configs.GetConfig().UserInterface.Formats.Time)
return m.DateSent.Format(tFormat)
}
diff --git a/internal/users/userrecord.prompt.go b/internal/users/userrecord.prompt.go
index 3a070879..3ca97f12 100644
--- a/internal/users/userrecord.prompt.go
+++ b/internal/users/userrecord.prompt.go
@@ -48,7 +48,7 @@ func (u *UserRecord) GetCommandPrompt() string {
if len(promptOut) == 0 {
if promptDefaultCompiled == `` {
- promptDefaultCompiled = util.ConvertColorShortTags(configs.GetTextFormatsConfig().Prompt.String())
+ promptDefaultCompiled = util.ConvertColorShortTags(configs.GetUserInterfaceConfig().Formats.Prompt.String())
}
var customPrompt any = nil
diff --git a/modules/gmcp/gmcp.Char.Combat.Cooldown.go b/modules/gmcp/gmcp.Char.Combat.Cooldown.go
new file mode 100644
index 00000000..e6b77fe4
--- /dev/null
+++ b/modules/gmcp/gmcp.Char.Combat.Cooldown.go
@@ -0,0 +1,253 @@
+// Cooldown module provides high-frequency (5Hz) combat round timer updates.
+// Timer only runs when players are actively in combat to minimize CPU usage.
+package gmcp
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/GoMudEngine/GoMud/internal/configs"
+ "github.com/GoMudEngine/GoMud/internal/events"
+ "github.com/GoMudEngine/GoMud/internal/mudlog"
+ "github.com/GoMudEngine/GoMud/internal/users"
+)
+
+type GMCPCombatCooldownUpdate struct {
+ UserId int
+ CooldownSeconds float64
+ MaxSeconds float64
+ NameActive string
+ NameIdle string
+}
+
+func (g GMCPCombatCooldownUpdate) Type() string { return `GMCPCombatCooldownUpdate` }
+
+// CombatCooldownTimer sends 5Hz updates for smooth UI countdown animations
+type CombatCooldownTimer struct {
+ ticker *time.Ticker
+ done chan bool
+ roundStarted time.Time
+ roundNumber uint64
+ roundMutex sync.RWMutex
+ playerMutex sync.RWMutex
+ players map[int]bool
+ running bool
+ runningMutex sync.Mutex
+}
+
+var cooldownTimer *CombatCooldownTimer
+
+func InitCombatCooldownTimer() {
+ cooldownTimer = &CombatCooldownTimer{
+ players: make(map[int]bool),
+ done: make(chan bool),
+ }
+
+ events.RegisterListener(events.NewRound{}, cooldownTimer.handleNewRound)
+ events.RegisterListener(GMCPCombatCooldownUpdate{}, handleCombatCooldownUpdate)
+
+}
+
+func (ct *CombatCooldownTimer) handleNewRound(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.NewRound)
+ if !ok {
+ mudlog.Error("GMCPCombatCooldown", "action", "handleNewRound", "error", "type assertion failed", "expectedType", "events.NewRound", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ ct.roundMutex.Lock()
+ ct.roundStarted = evt.TimeNow
+ ct.roundNumber = evt.RoundNumber
+ ct.roundMutex.Unlock()
+
+ return events.Continue
+}
+
+func (ct *CombatCooldownTimer) AddPlayer(userId int) {
+ ct.playerMutex.Lock()
+ ct.players[userId] = true
+ needsStart := len(ct.players) == 1
+ ct.playerMutex.Unlock()
+
+ mudlog.Info("CombatCooldownTimer", "action", "AddPlayer", "userId", userId, "needsStart", needsStart)
+
+ if needsStart {
+ ct.start()
+ }
+}
+
+func (ct *CombatCooldownTimer) RemovePlayer(userId int) {
+ ct.playerMutex.Lock()
+ delete(ct.players, userId)
+ shouldStop := len(ct.players) == 0
+ ct.playerMutex.Unlock()
+
+ if shouldStop {
+ ct.stop()
+ }
+}
+
+func (ct *CombatCooldownTimer) start() {
+ ct.runningMutex.Lock()
+ defer ct.runningMutex.Unlock()
+
+ if ct.running {
+ return
+ }
+
+ ct.running = true
+ // 200ms interval provides 5 updates per second for smooth countdown animation
+ ct.ticker = time.NewTicker(200 * time.Millisecond)
+
+ go func() {
+ for {
+ select {
+ case <-ct.ticker.C:
+ ct.sendUpdates()
+ case <-ct.done:
+ return
+ }
+ }
+ }()
+
+ mudlog.Info("CombatCooldownTimer", "action", "started")
+}
+
+func (ct *CombatCooldownTimer) stop() {
+ ct.runningMutex.Lock()
+ defer ct.runningMutex.Unlock()
+
+ if !ct.running {
+ return
+ }
+
+ ct.running = false
+ ct.ticker.Stop()
+
+ // Non-blocking send prevents deadlock if channel is full
+ select {
+ case ct.done <- true:
+ default:
+ }
+
+ mudlog.Info("CombatCooldownTimer", "action", "stopped")
+}
+
+func (ct *CombatCooldownTimer) sendUpdates() {
+ ct.roundMutex.RLock()
+ roundStarted := ct.roundStarted
+ ct.roundMutex.RUnlock()
+
+ if roundStarted.IsZero() {
+ roundStarted = time.Now()
+ }
+
+ timingConfig := configs.GetTimingConfig()
+ roundDuration := time.Duration(timingConfig.RoundSeconds) * time.Second
+
+ elapsed := time.Since(roundStarted)
+ remainingMs := roundDuration - elapsed
+ if remainingMs < 0 {
+ remainingMs = 0
+ }
+
+ remainingSeconds := float64(remainingMs) / float64(time.Second)
+ maxSeconds := float64(roundDuration) / float64(time.Second)
+
+ ct.playerMutex.RLock()
+ playerIds := make([]int, 0, len(ct.players))
+ for userId := range ct.players {
+ playerIds = append(playerIds, userId)
+ }
+ ct.playerMutex.RUnlock()
+
+ if len(playerIds) > 0 {
+ }
+
+ for _, userId := range playerIds {
+ user := users.GetByUserId(userId)
+ if user == nil {
+ ct.playerMutex.Lock()
+ delete(ct.players, userId)
+ ct.playerMutex.Unlock()
+ mudlog.Warn("CombatCooldownTimer", "action", "sendUpdates", "issue", "user not found, cleaning up stale entry", "userId", userId)
+ continue
+ }
+
+ // Cooldown only shows when player is attacking (has aggro set)
+ if user.Character.Aggro == nil {
+ continue
+ }
+
+ events.AddToQueue(GMCPCombatCooldownUpdate{
+ UserId: userId,
+ CooldownSeconds: remainingSeconds,
+ MaxSeconds: maxSeconds,
+ NameActive: "Combat Round",
+ NameIdle: "Ready",
+ })
+ }
+}
+
+func TrackCombatPlayer(userId int) {
+ user := users.GetByUserId(userId)
+ if user == nil {
+ mudlog.Warn("CombatCooldownTimer", "action", "TrackCombatPlayer", "issue", "attempted to track non-existent user", "userId", userId)
+ return
+ }
+
+ if cooldownTimer != nil {
+ cooldownTimer.AddPlayer(userId)
+ }
+}
+
+func UntrackCombatPlayer(userId int) {
+ if cooldownTimer != nil {
+ user := users.GetByUserId(userId)
+ if user != nil {
+ // Send final 0.0 update before removing
+ timingConfig := configs.GetTimingConfig()
+ maxSeconds := float64(timingConfig.RoundSeconds)
+
+ // Send final 0.0 update before removing to signal combat end
+ handleCombatCooldownUpdate(GMCPCombatCooldownUpdate{
+ UserId: userId,
+ CooldownSeconds: 0.0,
+ MaxSeconds: maxSeconds,
+ NameActive: "Combat Round",
+ NameIdle: "Ready",
+ })
+ }
+
+ cooldownTimer.RemovePlayer(userId)
+ }
+}
+
+func handleCombatCooldownUpdate(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(GMCPCombatCooldownUpdate)
+ if !typeOk {
+ mudlog.Error("GMCPCombatCooldown", "action", "handleCombatCooldownUpdate", "error", "type assertion failed", "expectedType", "GMCPCombatCooldownUpdate", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ _, valid := validateUserForGMCP(evt.UserId, "GMCPCombatCooldown")
+ if !valid {
+ return events.Continue
+ }
+
+ payload := map[string]interface{}{
+ "cooldown": fmt.Sprintf("%.1f", evt.CooldownSeconds),
+ "max_cooldown": fmt.Sprintf("%.1f", evt.MaxSeconds),
+ "name_active": evt.NameActive,
+ "name_idle": evt.NameIdle,
+ }
+
+ events.AddToQueue(GMCPOut{
+ UserId: evt.UserId,
+ Module: "Char.Combat.Cooldown",
+ Payload: payload,
+ })
+
+ return events.Continue
+}
diff --git a/modules/gmcp/gmcp.Char.Combat.Damage.go b/modules/gmcp/gmcp.Char.Combat.Damage.go
new file mode 100644
index 00000000..a796d2a4
--- /dev/null
+++ b/modules/gmcp/gmcp.Char.Combat.Damage.go
@@ -0,0 +1,136 @@
+// Package gmcp handles Combat Damage notification updates for GMCP.
+//
+// Stateless module that immediately forwards damage/healing events to players.
+// No deduplication needed as each damage event is meaningful.
+package gmcp
+
+import (
+ "fmt"
+
+ "github.com/GoMudEngine/GoMud/internal/events"
+ "github.com/GoMudEngine/GoMud/internal/mudlog"
+)
+
+// GMCPCombatDamageUpdate is sent when damage or healing occurs
+type GMCPCombatDamageUpdate struct {
+ UserId int
+ Amount int // Positive for damage, negative for healing
+ DamageType string // "physical", "magical", "heal", etc.
+ Source string // Name of attacker/healer
+ Target string // Name of target
+}
+
+func (g GMCPCombatDamageUpdate) Type() string { return `GMCPCombatDamageUpdate` }
+
+func init() {
+ // Register listener for actual damage events from combat
+ events.RegisterListener(events.DamageDealt{}, handleDamageDealtForGMCP)
+ events.RegisterListener(events.HealingReceived{}, handleHealingReceivedForGMCP)
+
+ // Keep the internal event for backward compatibility
+ events.RegisterListener(GMCPCombatDamageUpdate{}, handleCombatDamageUpdate)
+
+}
+
+func handleCombatDamageUpdate(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(GMCPCombatDamageUpdate)
+ if !typeOk {
+ mudlog.Error("GMCPCombatDamage", "action", "handleCombatDamageUpdate", "error", "type assertion failed", "expectedType", "GMCPCombatDamageUpdate", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ _, valid := validateUserForGMCP(evt.UserId, "GMCPCombatDamage")
+ if !valid {
+ return events.Continue
+ }
+
+ // Build the payload
+ payload := map[string]interface{}{
+ "amount": evt.Amount,
+ "type": evt.DamageType,
+ "source": evt.Source,
+ "target": evt.Target,
+ }
+
+ events.AddToQueue(GMCPOut{
+ UserId: evt.UserId,
+ Module: "Char.Combat.Damage",
+ Payload: payload,
+ })
+
+ return events.Continue
+}
+
+// handleDamageDealtForGMCP processes DamageDealt events and sends GMCP updates
+func handleDamageDealtForGMCP(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(events.DamageDealt)
+ if !typeOk {
+ mudlog.Error("GMCPCombatDamage", "action", "handleDamageDealtForGMCP", "error", "type assertion failed", "expectedType", "events.DamageDealt", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ // Only send to players (not mobs)
+ if evt.TargetType == "player" {
+ events.AddToQueue(GMCPCombatDamageUpdate{
+ UserId: evt.TargetId,
+ Amount: evt.Amount,
+ DamageType: evt.DamageType,
+ Source: evt.SourceName,
+ Target: evt.TargetName,
+ })
+ }
+
+ // Also send to the attacker if they're a player
+ if evt.SourceType == "player" {
+ events.AddToQueue(GMCPCombatDamageUpdate{
+ UserId: evt.SourceId,
+ Amount: evt.Amount,
+ DamageType: evt.DamageType,
+ Source: evt.SourceName,
+ Target: evt.TargetName,
+ })
+ }
+
+ return events.Continue
+}
+
+// handleHealingReceivedForGMCP processes HealingReceived events and sends GMCP updates
+func handleHealingReceivedForGMCP(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(events.HealingReceived)
+ if !typeOk {
+ mudlog.Error("GMCPCombatDamage", "action", "handleHealingReceivedForGMCP", "error", "type assertion failed", "expectedType", "events.HealingReceived", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ // Only send to players
+ if evt.TargetType == "player" {
+ events.AddToQueue(GMCPCombatDamageUpdate{
+ UserId: evt.TargetId,
+ Amount: -evt.Amount, // Negative for healing
+ DamageType: "heal",
+ Source: evt.SourceName,
+ Target: evt.TargetName,
+ })
+ }
+
+ return events.Continue
+}
+
+// SendCombatDamage sends a damage/healing update
+// This is exported so it can be called from combat code
+func SendCombatDamage(userId int, amount int, damageType string, source string, target string) {
+ // Validate user exists before sending damage update
+ _, valid := validateUserForGMCP(userId, "GMCPCombatDamage")
+ if !valid {
+ return
+ }
+
+ // Queue the update event
+ events.AddToQueue(GMCPCombatDamageUpdate{
+ UserId: userId,
+ Amount: amount,
+ DamageType: damageType,
+ Source: source,
+ Target: target,
+ })
+}
diff --git a/modules/gmcp/gmcp.Char.Combat.Enemies.go b/modules/gmcp/gmcp.Char.Combat.Enemies.go
new file mode 100644
index 00000000..6dd80c03
--- /dev/null
+++ b/modules/gmcp/gmcp.Char.Combat.Enemies.go
@@ -0,0 +1,428 @@
+// Package gmcp handles Combat Enemies list updates for GMCP.
+//
+// Event-driven enemy tracking that shows all combat participants.
+// Enemies are added when: player attacks them OR they attack the player.
+package gmcp
+
+import (
+ "fmt"
+ "sync"
+
+ "github.com/GoMudEngine/GoMud/internal/events"
+ "github.com/GoMudEngine/GoMud/internal/mobs"
+ "github.com/GoMudEngine/GoMud/internal/mudlog"
+ "github.com/GoMudEngine/GoMud/internal/rooms"
+ "github.com/GoMudEngine/GoMud/internal/users"
+ "github.com/GoMudEngine/GoMud/internal/util"
+)
+
+// GMCPCombatEnemiesUpdate is sent when the list of enemies changes
+type GMCPCombatEnemiesUpdate struct {
+ UserId int
+ Enemies []EnemyInfo
+}
+
+type EnemyInfo struct {
+ Name string `json:"name"`
+ Id int `json:"id"`
+ IsPrimary bool `json:"is_primary"`
+}
+
+func (g GMCPCombatEnemiesUpdate) Type() string { return `GMCPCombatEnemiesUpdate` }
+
+var (
+ // enemiesMutexNew protects the enemies tracking map
+ enemiesMutexNew sync.RWMutex
+
+ // userEnemiesNew tracks all enemies for each user
+ userEnemiesNew = make(map[int]map[int]*EnemyInfoFull) // userId -> map[enemyId]info
+)
+
+type EnemyInfoFull struct {
+ Name string
+ Id int
+ Type string // "mob" or "player"
+ IsPrimary bool // true if this is the user's target
+ LastRound uint64 // Last round they were in combat
+}
+
+func init() {
+ // Register the GMCP output handler
+ events.RegisterListener(GMCPCombatEnemiesUpdate{}, handleCombatEnemiesUpdate)
+
+ // Listen for combat events
+ events.RegisterListener(events.CombatStarted{}, handleEnemiesCombatStarted)
+ events.RegisterListener(events.DamageDealt{}, handleEnemiesDamageDealt)
+ events.RegisterListener(events.AttackAvoided{}, handleEnemiesAttackAvoided)
+ events.RegisterListener(events.MobDeath{}, handleEnemiesMobDeathNew)
+ events.RegisterListener(events.PlayerDeath{}, handleEnemiesPlayerDeath)
+ events.RegisterListener(events.RoomChange{}, handleEnemiesRoomChangeNew)
+ events.RegisterListener(events.CombatEnded{}, handleEnemiesCombatEnded)
+ events.RegisterListener(events.NewRound{}, handleEnemiesCleanup)
+}
+
+func handleCombatEnemiesUpdate(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(GMCPCombatEnemiesUpdate)
+ if !typeOk {
+ mudlog.Error("GMCPCombatEnemies", "action", "handleCombatEnemiesUpdate", "error", "type assertion failed", "expectedType", "GMCPCombatEnemiesUpdate", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ _, valid := validateUserForGMCP(evt.UserId, "GMCPCombatEnemies")
+ if !valid {
+ return events.Continue
+ }
+
+ enemies := make([]map[string]interface{}, len(evt.Enemies))
+ for i, enemy := range evt.Enemies {
+ enemies[i] = map[string]interface{}{
+ "name": enemy.Name,
+ "id": enemy.Id,
+ "is_primary": enemy.IsPrimary,
+ }
+ }
+
+ events.AddToQueue(GMCPOut{
+ UserId: evt.UserId,
+ Module: "Char.Combat.Enemies",
+ Payload: enemies,
+ })
+
+ return events.Continue
+}
+
+// handleEnemiesCombatStarted adds enemies when combat starts
+func handleEnemiesCombatStarted(e events.Event) events.ListenerReturn {
+ mudlog.Info("GMCPCombatEnemies", "event", "CombatStarted received in Enemies module")
+ evt, ok := e.(events.CombatStarted)
+ if !ok {
+ mudlog.Error("GMCPCombatEnemies", "error", "CombatStarted type assertion failed")
+ return events.Continue
+ }
+
+ mudlog.Info("GMCPCombatEnemies", "attackerType", evt.AttackerType, "attackerId", evt.AttackerId,
+ "defenderType", evt.DefenderType, "defenderId", evt.DefenderId)
+
+ // If player attacks mob/player, add defender to attacker's enemy list
+ if evt.AttackerType == "player" {
+ addEnemy(evt.AttackerId, evt.DefenderId, evt.DefenderType, evt.DefenderName, true)
+ }
+
+ // If mob/player attacks player, add attacker to defender's enemy list
+ if evt.DefenderType == "player" {
+ addEnemy(evt.DefenderId, evt.AttackerId, evt.AttackerType, evt.AttackerName, false)
+ }
+
+ return events.Continue
+}
+
+// handleEnemiesDamageDealt ensures enemies are tracked when damage occurs
+func handleEnemiesDamageDealt(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.DamageDealt)
+ if !ok {
+ return events.Continue
+ }
+
+ // If damage is dealt to a player, ensure attacker is in their enemy list
+ if evt.TargetType == "player" {
+ addEnemy(evt.TargetId, evt.SourceId, evt.SourceType, evt.SourceName, false)
+ }
+
+ // If player deals damage, ensure target is in their enemy list
+ if evt.SourceType == "player" {
+ addEnemy(evt.SourceId, evt.TargetId, evt.TargetType, evt.TargetName, isUserTarget(evt.SourceId, evt.TargetId, evt.TargetType))
+ }
+
+ return events.Continue
+}
+
+// handleEnemiesAttackAvoided handles missed attacks (still combat participation)
+func handleEnemiesAttackAvoided(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.AttackAvoided)
+ if !ok {
+ return events.Continue
+ }
+
+ // If attack was avoided by player, attacker is still an enemy
+ if evt.DefenderType == "player" {
+ addEnemy(evt.DefenderId, evt.AttackerId, evt.AttackerType, evt.AttackerName, false)
+ }
+
+ // If player's attack was avoided, target is still an enemy
+ if evt.AttackerType == "player" {
+ addEnemy(evt.AttackerId, evt.DefenderId, evt.DefenderType, evt.DefenderName, isUserTarget(evt.AttackerId, evt.DefenderId, evt.DefenderType))
+ }
+
+ return events.Continue
+}
+
+// handleEnemiesMobDeathNew removes dead mobs from enemy lists
+func handleEnemiesMobDeathNew(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.MobDeath)
+ if !ok {
+ return events.Continue
+ }
+
+ enemiesMutexNew.Lock()
+ usersToUpdate := []int{}
+ for userId, enemies := range userEnemiesNew {
+ if enemy, exists := enemies[evt.InstanceId]; exists && enemy.Type == "mob" {
+ delete(enemies, evt.InstanceId)
+ if len(enemies) == 0 {
+ delete(userEnemiesNew, userId)
+ }
+ usersToUpdate = append(usersToUpdate, userId)
+ }
+ }
+ enemiesMutexNew.Unlock()
+
+ for _, userId := range usersToUpdate {
+ sendEnemiesUpdateNew(userId)
+ }
+
+ return events.Continue
+}
+
+// handleEnemiesPlayerDeath removes dead players from enemy lists
+func handleEnemiesPlayerDeath(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.PlayerDeath)
+ if !ok {
+ return events.Continue
+ }
+
+ // Also clear the dead player's enemy list
+ enemiesMutexNew.Lock()
+ delete(userEnemiesNew, evt.UserId)
+ enemiesMutexNew.Unlock()
+
+ // Remove from other players' enemy lists
+ enemiesMutexNew.Lock()
+ usersToUpdate := []int{}
+ for userId, enemies := range userEnemiesNew {
+ if enemy, exists := enemies[evt.UserId]; exists && enemy.Type == "player" {
+ delete(enemies, evt.UserId)
+ if len(enemies) == 0 {
+ delete(userEnemiesNew, userId)
+ }
+ usersToUpdate = append(usersToUpdate, userId)
+ }
+ }
+ enemiesMutexNew.Unlock()
+
+ for _, userId := range usersToUpdate {
+ sendEnemiesUpdateNew(userId)
+ }
+
+ return events.Continue
+}
+
+// handleEnemiesRoomChangeNew handles when enemies move away
+func handleEnemiesRoomChangeNew(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.RoomChange)
+ if !ok {
+ return events.Continue
+ }
+
+ // Handle mob movement
+ if evt.MobInstanceId != 0 {
+ enemiesMutexNew.Lock()
+ usersToUpdate := []int{}
+ for userId, enemies := range userEnemiesNew {
+ if enemy, exists := enemies[evt.MobInstanceId]; exists && enemy.Type == "mob" {
+ user := users.GetByUserId(userId)
+ if user != nil && user.Character.RoomId != evt.ToRoomId {
+ delete(enemies, evt.MobInstanceId)
+ if len(enemies) == 0 {
+ delete(userEnemiesNew, userId)
+ }
+ usersToUpdate = append(usersToUpdate, userId)
+ }
+ }
+ }
+ enemiesMutexNew.Unlock()
+
+ for _, userId := range usersToUpdate {
+ sendEnemiesUpdateNew(userId)
+ }
+ }
+
+ return events.Continue
+}
+
+// handleEnemiesCombatEnded clears enemy list when player's combat ends
+func handleEnemiesCombatEnded(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.CombatEnded)
+ if !ok || evt.EntityType != "player" {
+ return events.Continue
+ }
+
+ enemiesMutexNew.Lock()
+ delete(userEnemiesNew, evt.EntityId)
+ enemiesMutexNew.Unlock()
+
+ // Send empty enemy list
+ handleCombatEnemiesUpdate(GMCPCombatEnemiesUpdate{
+ UserId: evt.EntityId,
+ Enemies: []EnemyInfo{},
+ })
+
+ return events.Continue
+}
+
+// handleEnemiesCleanup removes stale enemies who haven't acted in a while
+func handleEnemiesCleanup(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.NewRound)
+ if !ok {
+ return events.Continue
+ }
+
+ const staleRounds = uint64(10) // Remove enemies who haven't acted in 10 rounds
+ currentRound := evt.RoundNumber
+
+ enemiesMutexNew.Lock()
+ usersToUpdate := []int{}
+ for userId, enemies := range userEnemiesNew {
+ changed := false
+ for enemyId, enemy := range enemies {
+ if currentRound-enemy.LastRound > staleRounds {
+ delete(enemies, enemyId)
+ changed = true
+ }
+ }
+ if changed {
+ if len(enemies) == 0 {
+ delete(userEnemiesNew, userId)
+ }
+ usersToUpdate = append(usersToUpdate, userId)
+ }
+ }
+ enemiesMutexNew.Unlock()
+
+ for _, userId := range usersToUpdate {
+ sendEnemiesUpdateNew(userId)
+ }
+
+ return events.Continue
+}
+
+// addEnemy adds an enemy to a user's enemy list
+func addEnemy(userId int, enemyId int, enemyType string, enemyName string, isPrimary bool) {
+ // Validate user has GMCP
+ _, valid := validateUserForGMCP(userId, "GMCPCombatEnemies")
+ if !valid {
+ return
+ }
+
+ enemiesMutexNew.Lock()
+ if userEnemiesNew[userId] == nil {
+ userEnemiesNew[userId] = make(map[int]*EnemyInfoFull)
+ }
+
+ // Check if already exists
+ if existing, exists := userEnemiesNew[userId][enemyId]; exists {
+ existing.LastRound = util.GetRoundCount()
+ if isPrimary {
+ existing.IsPrimary = true
+ }
+ } else {
+ userEnemiesNew[userId][enemyId] = &EnemyInfoFull{
+ Name: util.StripANSI(enemyName),
+ Id: enemyId,
+ Type: enemyType,
+ IsPrimary: isPrimary,
+ LastRound: util.GetRoundCount(),
+ }
+ }
+ enemiesMutexNew.Unlock()
+
+ mudlog.Info("GMCPCombatEnemies", "action", "Enemy added", "userId", userId,
+ "enemyName", enemyName, "enemyType", enemyType, "isPrimary", isPrimary)
+
+ // Send update
+ sendEnemiesUpdateNew(userId)
+}
+
+// isUserTarget checks if an enemy is the user's current target
+func isUserTarget(userId int, enemyId int, enemyType string) bool {
+ targetMutexNew.RLock()
+ target := userTargetsNew[userId]
+ targetMutexNew.RUnlock()
+
+ if target == nil {
+ return false
+ }
+
+ return target.Id == enemyId && target.Type == enemyType
+}
+
+// sendEnemiesUpdateNew sends current enemy list for a user
+func sendEnemiesUpdateNew(userId int) {
+ enemiesMutexNew.RLock()
+ enemyMap := userEnemiesNew[userId]
+ enemiesMutexNew.RUnlock()
+
+ enemies := []EnemyInfo{}
+
+ // Get user's current room to verify enemies are still present
+ user := users.GetByUserId(userId)
+ if user == nil {
+ return
+ }
+
+ room := rooms.LoadRoom(user.Character.RoomId)
+ if room == nil {
+ return
+ }
+
+ for _, enemy := range enemyMap {
+ // Verify enemy still exists and is in same room
+ stillExists := false
+
+ if enemy.Type == "mob" {
+ for _, mobId := range room.GetMobs() {
+ if mobId == enemy.Id {
+ if mob := mobs.GetInstance(mobId); mob != nil {
+ stillExists = true
+ enemies = append(enemies, EnemyInfo{
+ Name: enemy.Name,
+ Id: enemy.Id,
+ IsPrimary: enemy.IsPrimary,
+ })
+ }
+ break
+ }
+ }
+ } else if enemy.Type == "player" {
+ for _, playerId := range room.GetPlayers() {
+ if playerId == enemy.Id {
+ stillExists = true
+ enemies = append(enemies, EnemyInfo{
+ Name: enemy.Name,
+ Id: enemy.Id,
+ IsPrimary: enemy.IsPrimary,
+ })
+ break
+ }
+ }
+ }
+
+ // Clean up if enemy no longer exists
+ if !stillExists {
+ enemiesMutexNew.Lock()
+ delete(enemyMap, enemy.Id)
+ enemiesMutexNew.Unlock()
+ }
+ }
+
+ handleCombatEnemiesUpdate(GMCPCombatEnemiesUpdate{
+ UserId: userId,
+ Enemies: enemies,
+ })
+}
+
+// cleanupCombatEnemiesNew removes all enemy tracking for a user
+func cleanupCombatEnemiesNew(userId int) {
+ enemiesMutexNew.Lock()
+ delete(userEnemiesNew, userId)
+ enemiesMutexNew.Unlock()
+}
diff --git a/modules/gmcp/gmcp.Char.Combat.Events.go b/modules/gmcp/gmcp.Char.Combat.Events.go
new file mode 100644
index 00000000..c55d3138
--- /dev/null
+++ b/modules/gmcp/gmcp.Char.Combat.Events.go
@@ -0,0 +1,235 @@
+// Package gmcp handles Combat Event notifications for GMCP.
+//
+// Stateless event transformer that converts internal combat events into GMCP messages.
+// Each event (CombatStarted, AttackMissed, etc.) is meaningful and sent immediately.
+package gmcp
+
+import (
+ "fmt"
+
+ "github.com/GoMudEngine/GoMud/internal/events"
+ "github.com/GoMudEngine/GoMud/internal/mudlog"
+ "github.com/GoMudEngine/GoMud/internal/util"
+)
+
+// GMCPCombatEvent is a generic GMCP event for combat notifications
+type GMCPCombatEvent struct {
+ UserId int
+ EventType string
+ Data map[string]interface{}
+}
+
+func (g GMCPCombatEvent) Type() string { return `GMCPCombatEvent` }
+
+func init() {
+ // Register listener for GMCP combat events
+ events.RegisterListener(GMCPCombatEvent{}, handleCombatEvent)
+
+ // Register listeners for all combat events
+ events.RegisterListener(events.CombatStarted{}, handleCombatStarted)
+ events.RegisterListener(events.CombatEnded{}, handleCombatEnded)
+ events.RegisterListener(events.DamageDealt{}, handleDamageDealt)
+ events.RegisterListener(events.AttackAvoided{}, handleAttackAvoided)
+ events.RegisterListener(events.CombatantFled{}, handleCombatantFled)
+
+}
+
+// handleCombatEvent sends GMCP combat events
+func handleCombatEvent(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(GMCPCombatEvent)
+ if !typeOk {
+ mudlog.Error("GMCPCombatEvents", "action", "handleCombatEvent", "error", "type assertion failed", "expectedType", "GMCPCombatEvent", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ _, valid := validateUserForGMCP(evt.UserId, "GMCPCombatEvents")
+ if !valid {
+ return events.Continue
+ }
+
+ events.AddToQueue(GMCPOut{
+ UserId: evt.UserId,
+ Module: fmt.Sprintf("Char.Combat.%s", evt.EventType),
+ Payload: evt.Data,
+ })
+
+ return events.Continue
+}
+
+// handleCombatStarted sends GMCP notification when combat begins
+func handleCombatStarted(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(events.CombatStarted)
+ if !typeOk {
+ mudlog.Error("GMCPCombatEvents", "action", "handleCombatStarted", "error", "type assertion failed", "expectedType", "events.CombatStarted", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ // Send to attacker if they're a player
+ if evt.AttackerType == "player" {
+ events.AddToQueue(GMCPCombatEvent{
+ UserId: evt.AttackerId,
+ EventType: "Started",
+ Data: map[string]interface{}{
+ "role": "attacker",
+ "targetId": evt.DefenderId,
+ "targetType": evt.DefenderType,
+ "targetName": util.StripANSI(evt.DefenderName),
+ "initiatedBy": evt.InitiatedBy,
+ },
+ })
+ }
+
+ // Send to defender if they're a player
+ if evt.DefenderType == "player" {
+ events.AddToQueue(GMCPCombatEvent{
+ UserId: evt.DefenderId,
+ EventType: "Started",
+ Data: map[string]interface{}{
+ "role": "defender",
+ "attackerId": evt.AttackerId,
+ "attackerType": evt.AttackerType,
+ "attackerName": util.StripANSI(evt.AttackerName),
+ "initiatedBy": evt.InitiatedBy,
+ },
+ })
+ }
+
+ return events.Continue
+}
+
+// handleCombatEnded sends GMCP notification when combat ends
+func handleCombatEnded(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(events.CombatEnded)
+ if !typeOk {
+ mudlog.Error("GMCPCombatEvents", "action", "handleCombatEnded", "error", "type assertion failed", "expectedType", "events.CombatEnded", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ // Only send to players
+ if evt.EntityType == "player" {
+ events.AddToQueue(GMCPCombatEvent{
+ UserId: evt.EntityId,
+ EventType: "Ended",
+ Data: map[string]interface{}{
+ "reason": evt.Reason,
+ "duration": evt.Duration,
+ },
+ })
+ }
+
+ return events.Continue
+}
+
+// handleDamageDealt sends GMCP notification for damage events
+func handleDamageDealt(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(events.DamageDealt)
+ if !typeOk {
+ mudlog.Error("GMCPCombatEvents", "action", "handleDamageDealt", "error", "type assertion failed", "expectedType", "events.DamageDealt", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ // Send to source if they're a player
+ if evt.SourceType == "player" {
+ events.AddToQueue(GMCPCombatEvent{
+ UserId: evt.SourceId,
+ EventType: "DamageDealt",
+ Data: map[string]interface{}{
+ "targetId": evt.TargetId,
+ "targetType": evt.TargetType,
+ "targetName": util.StripANSI(evt.TargetName),
+ "amount": evt.Amount,
+ "damageType": evt.DamageType,
+ "weaponName": util.StripANSI(evt.WeaponName),
+ "spellName": util.StripANSI(evt.SpellName),
+ "isCritical": evt.IsCritical,
+ "isKillingBlow": evt.IsKillingBlow,
+ },
+ })
+ }
+
+ // Send to target if they're a player
+ if evt.TargetType == "player" {
+ events.AddToQueue(GMCPCombatEvent{
+ UserId: evt.TargetId,
+ EventType: "DamageReceived",
+ Data: map[string]interface{}{
+ "sourceId": evt.SourceId,
+ "sourceType": evt.SourceType,
+ "sourceName": util.StripANSI(evt.SourceName),
+ "amount": evt.Amount,
+ "damageType": evt.DamageType,
+ "weaponName": util.StripANSI(evt.WeaponName),
+ "spellName": util.StripANSI(evt.SpellName),
+ "isCritical": evt.IsCritical,
+ "isKillingBlow": evt.IsKillingBlow,
+ },
+ })
+ }
+
+ return events.Continue
+}
+
+// handleAttackAvoided sends GMCP notification for avoided attacks
+func handleAttackAvoided(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(events.AttackAvoided)
+ if !typeOk {
+ mudlog.Error("GMCPCombatEvents", "action", "handleAttackAvoided", "error", "type assertion failed", "expectedType", "events.AttackAvoided", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ // Send to attacker if they're a player
+ if evt.AttackerType == "player" {
+ events.AddToQueue(GMCPCombatEvent{
+ UserId: evt.AttackerId,
+ EventType: "AttackMissed",
+ Data: map[string]interface{}{
+ "defenderId": evt.DefenderId,
+ "defenderType": evt.DefenderType,
+ "defenderName": util.StripANSI(evt.DefenderName),
+ "avoidType": evt.AvoidType,
+ "weaponName": util.StripANSI(evt.WeaponName),
+ },
+ })
+ }
+
+ // Send to defender if they're a player
+ if evt.DefenderType == "player" {
+ events.AddToQueue(GMCPCombatEvent{
+ UserId: evt.DefenderId,
+ EventType: "AttackAvoided",
+ Data: map[string]interface{}{
+ "attackerId": evt.AttackerId,
+ "attackerType": evt.AttackerType,
+ "attackerName": util.StripANSI(evt.AttackerName),
+ "avoidType": evt.AvoidType,
+ "weaponName": util.StripANSI(evt.WeaponName),
+ },
+ })
+ }
+
+ return events.Continue
+}
+
+// handleCombatantFled sends GMCP notification when someone flees
+func handleCombatantFled(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(events.CombatantFled)
+ if !typeOk {
+ mudlog.Error("GMCPCombatEvents", "action", "handleCombatantFled", "error", "type assertion failed", "expectedType", "events.CombatantFled", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ // Only send to players
+ if evt.EntityType == "player" {
+ events.AddToQueue(GMCPCombatEvent{
+ UserId: evt.EntityId,
+ EventType: "Fled",
+ Data: map[string]interface{}{
+ "direction": evt.Direction,
+ "success": evt.Success,
+ "preventedBy": evt.PreventedBy,
+ },
+ })
+ }
+
+ return events.Continue
+}
diff --git a/modules/gmcp/gmcp.Char.Combat.Status.go b/modules/gmcp/gmcp.Char.Combat.Status.go
new file mode 100644
index 00000000..12cdb923
--- /dev/null
+++ b/modules/gmcp/gmcp.Char.Combat.Status.go
@@ -0,0 +1,280 @@
+// Package gmcp handles Combat Status updates for GMCP.
+//
+// Tracks combat state changes (entering/leaving combat) and sends updates only when state changes.
+// Uses round-based checks with immediate updates on vitals changes for accurate HP snapshots.
+package gmcp
+
+import (
+ "fmt"
+ "sync"
+
+ "github.com/GoMudEngine/GoMud/internal/events"
+ "github.com/GoMudEngine/GoMud/internal/mudlog"
+ "github.com/GoMudEngine/GoMud/internal/users"
+)
+
+// GMCPCombatStatusUpdate is sent when combat status changes (entering/leaving combat)
+type GMCPCombatStatusUpdate struct {
+ UserId int
+ InCombat bool
+ RoundNumber uint64 // Current round number
+}
+
+func (g GMCPCombatStatusUpdate) Type() string { return `GMCPCombatStatusUpdate` }
+
+var (
+ // stateMutex protects all the state maps
+ stateMutex sync.RWMutex
+
+ // userCombatState tracks whether user was in combat last update
+ userCombatState = make(map[int]bool) // userId -> wasInCombat
+
+ // lastRoundNumber tracks the last round number sent for each user
+ lastRoundNumber = make(map[int]uint64) // userId -> roundNumber
+)
+
+func init() {
+ events.RegisterListener(GMCPCombatStatusUpdate{}, handleCombatStatusUpdate)
+ events.RegisterListener(events.PlayerSpawn{}, handleStatusPlayerSpawn)
+ events.RegisterListener(events.NewRound{}, handleStatusNewRound)
+ events.RegisterListener(events.CharacterVitalsChanged{}, handleStatusVitalsChanged)
+}
+
+func handleCombatStatusUpdate(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(GMCPCombatStatusUpdate)
+ if !typeOk {
+ mudlog.Error("GMCPCombatStatus", "action", "handleCombatStatusUpdate", "error", "type assertion failed", "expectedType", "GMCPCombatStatusUpdate", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ _, valid := validateUserForGMCP(evt.UserId, "GMCPCombatStatus")
+ if !valid {
+ return events.Continue
+ }
+
+ // Build the payload - simplified to just combat state
+ payload := map[string]interface{}{
+ "in_combat": evt.InCombat,
+ }
+
+ // Only include round_number if it's set (for round-based combat)
+ if evt.RoundNumber > 0 {
+ payload["round_number"] = evt.RoundNumber
+ }
+
+ events.AddToQueue(GMCPOut{
+ UserId: evt.UserId,
+ Module: "Char.Combat.Status",
+ Payload: payload,
+ })
+
+ return events.Continue
+}
+
+// handlePlayerSpawn sends initial combat status on login
+func handleStatusPlayerSpawn(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(events.PlayerSpawn)
+ if !typeOk {
+ mudlog.Error("GMCPCombatStatus", "action", "handlePlayerSpawn", "error", "type assertion failed", "expectedType", "events.PlayerSpawn", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ if evt.UserId < 1 {
+ return events.Continue
+ }
+
+ // Check if user has aggro
+ user := users.GetByUserId(evt.UserId)
+ if user == nil {
+ mudlog.Warn("GMCPCombatStatus", "action", "handlePlayerSpawn", "issue", "user not found on spawn", "userId", evt.UserId)
+ return events.Continue
+ }
+
+ inCombat := user.Character.Aggro != nil
+ stateMutex.Lock()
+ userCombatState[evt.UserId] = inCombat
+ stateMutex.Unlock()
+
+ // Send initial combat status
+ sendCombatStatusUpdate(evt.UserId, inCombat, 0)
+
+ return events.Continue
+}
+
+// cleanupCombatStatus removes all status tracking for a user
+func cleanupCombatStatus(userId int) {
+ stateMutex.Lock()
+ delete(userCombatState, userId)
+ delete(lastRoundNumber, userId)
+ stateMutex.Unlock()
+}
+
+// handleNewRound checks for combat state changes each round
+func handleStatusNewRound(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(events.NewRound)
+ if !typeOk {
+ mudlog.Error("GMCPCombatStatus", "action", "handleNewRound", "error", "type assertion failed", "expectedType", "events.NewRound", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ // Only check users who are in combat or were recently in combat
+ stateMutex.RLock()
+ trackedUsers := make([]int, 0, len(userCombatState))
+ for userId := range userCombatState {
+ trackedUsers = append(trackedUsers, userId)
+ }
+ stateMutex.RUnlock()
+
+ // Also add any users currently in combat or being attacked
+ for _, userId := range GetUsersInOrTargetedByCombat() {
+ found := false
+ for _, tracked := range trackedUsers {
+ if tracked == userId {
+ found = true
+ break
+ }
+ }
+ if !found {
+ trackedUsers = append(trackedUsers, userId)
+ }
+ }
+
+ for _, userId := range trackedUsers {
+ user := users.GetByUserId(userId)
+ if user == nil {
+ stateMutex.Lock()
+ if _, exists := userCombatState[userId]; exists {
+ delete(userCombatState, userId)
+ delete(lastRoundNumber, userId)
+ mudlog.Warn("GMCPCombatStatus", "action", "handleNewRound", "issue", "user not found, cleaning up stale state", "userId", userId)
+ }
+ stateMutex.Unlock()
+ continue
+ }
+
+ // Use centralized combat detection
+ currentlyInCombat := IsUserInCombat(userId)
+
+ stateMutex.RLock()
+ wasInCombat := userCombatState[userId]
+ stateMutex.RUnlock()
+
+ // Update state and round number if needed
+ if currentlyInCombat != wasInCombat || currentlyInCombat {
+ stateMutex.Lock()
+ if currentlyInCombat != wasInCombat {
+ userCombatState[userId] = currentlyInCombat
+ }
+ if currentlyInCombat {
+ lastRoundNumber[userId] = evt.RoundNumber
+ }
+ stateMutex.Unlock()
+ }
+
+ // Only send updates when combat state changes (entering/leaving combat)
+ // HP updates will come from CharacterVitalsChanged events after damage
+ needsUpdate := currentlyInCombat != wasInCombat
+
+ if needsUpdate {
+ // Send immediate update only for state changes
+ sendCombatStatusUpdate(userId, currentlyInCombat, evt.RoundNumber)
+
+ // Fire CombatEnded event when combat truly ends
+ if wasInCombat && !currentlyInCombat {
+ events.AddToQueue(events.CombatEnded{
+ EntityId: userId,
+ EntityType: "player",
+ Reason: "combat-complete",
+ })
+ }
+ }
+ }
+
+ return events.Continue
+}
+
+// handleVitalsChanged sends immediate updates when character vitals change
+func handleStatusVitalsChanged(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(events.CharacterVitalsChanged)
+ if !typeOk {
+ mudlog.Error("GMCPCombatStatus", "action", "handleVitalsChanged", "error", "type assertion failed", "expectedType", "events.CharacterVitalsChanged", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ // Only care about user changes that could affect combat
+ if evt.UserId < 1 {
+ return events.Continue
+ }
+
+ user := users.GetByUserId(evt.UserId)
+ if user == nil {
+ stateMutex.Lock()
+ if _, exists := userCombatState[evt.UserId]; exists {
+ delete(userCombatState, evt.UserId)
+ delete(lastRoundNumber, evt.UserId)
+ mudlog.Warn("GMCPCombatStatus", "action", "handleVitalsChanged", "issue", "user not found, cleaning up stale state", "userId", evt.UserId)
+ }
+ stateMutex.Unlock()
+ return events.Continue
+ }
+
+ // Use centralized combat detection
+ currentlyInCombat := IsUserInCombat(evt.UserId)
+
+ stateMutex.RLock()
+ wasInCombat := userCombatState[evt.UserId]
+ stateChanged := currentlyInCombat != wasInCombat
+ roundNum := lastRoundNumber[evt.UserId]
+ stateMutex.RUnlock()
+
+ // Update state if changed
+ if stateChanged {
+ stateMutex.Lock()
+ userCombatState[evt.UserId] = currentlyInCombat
+ stateMutex.Unlock()
+ }
+
+ // Send updates on state changes AND during combat (for pre-round HP snapshot)
+ if stateChanged || currentlyInCombat {
+
+ // Send immediate update - this captures HP at start of round
+ sendCombatStatusUpdate(evt.UserId, currentlyInCombat, roundNum)
+
+ // Update cooldown tracking based on aggro state (not just combat state)
+ // Cooldown only runs when player is actively fighting
+ if user.Character.Aggro != nil {
+ TrackCombatPlayer(evt.UserId)
+ } else {
+ UntrackCombatPlayer(evt.UserId)
+ }
+
+ // Fire CombatEnded event when combat truly ends
+ if stateChanged && !currentlyInCombat {
+ // Combat has truly ended (no aggro and not being attacked)
+ events.AddToQueue(events.CombatEnded{
+ EntityId: evt.UserId,
+ EntityType: "player",
+ Reason: "combat-complete",
+ })
+ }
+ }
+
+ return events.Continue
+}
+
+// sendCombatStatusUpdate sends a combat status update for a user
+func sendCombatStatusUpdate(userId int, inCombat bool, roundNumber uint64) {
+ user := users.GetByUserId(userId)
+ if user == nil {
+ mudlog.Warn("GMCPCombatStatus", "action", "sendCombatStatusUpdate", "issue", "attempted to send update for non-existent user", "userId", userId)
+ return
+ }
+
+ update := GMCPCombatStatusUpdate{
+ UserId: userId,
+ InCombat: inCombat,
+ RoundNumber: roundNumber,
+ }
+
+ handleCombatStatusUpdate(update)
+}
diff --git a/modules/gmcp/gmcp.Char.Combat.Target.go b/modules/gmcp/gmcp.Char.Combat.Target.go
new file mode 100644
index 00000000..e79b927b
--- /dev/null
+++ b/modules/gmcp/gmcp.Char.Combat.Target.go
@@ -0,0 +1,349 @@
+// Package gmcp handles Combat Target updates for GMCP.
+//
+// Event-driven target tracking that updates immediately when combat starts.
+// Tracks the player's current combat target with HP updates.
+package gmcp
+
+import (
+ "fmt"
+ "sync"
+
+ "github.com/GoMudEngine/GoMud/internal/events"
+ "github.com/GoMudEngine/GoMud/internal/mobs"
+ "github.com/GoMudEngine/GoMud/internal/mudlog"
+ "github.com/GoMudEngine/GoMud/internal/users"
+ "github.com/GoMudEngine/GoMud/internal/util"
+)
+
+// GMCPCombatTargetUpdate is sent when a player's combat target changes or target HP changes
+type GMCPCombatTargetUpdate struct {
+ UserId int
+ TargetName string // Name of current target
+ TargetHpCurrent int // Current HP of target
+ TargetHpMax int // Max HP of target
+}
+
+func (g GMCPCombatTargetUpdate) Type() string { return `GMCPCombatTargetUpdate` }
+
+var (
+ // targetMutexNew protects the target tracking maps
+ targetMutexNew sync.RWMutex
+
+ // userTargetsNew tracks the current target for each user
+ userTargetsNew = make(map[int]*TargetInfo) // userId -> target info
+)
+
+type TargetInfo struct {
+ Id int
+ Name string
+ Type string // "mob" or "player"
+ LastHP int
+ LastMaxHP int
+}
+
+func init() {
+ // Register the GMCP output handler
+ events.RegisterListener(GMCPCombatTargetUpdate{}, handleCombatTargetUpdate)
+
+ // Listen for combat events
+ events.RegisterListener(events.CombatStarted{}, handleTargetCombatStarted)
+ events.RegisterListener(events.MobVitalsChanged{}, handleTargetVitalsChanged)
+ events.RegisterListener(events.MobDeath{}, handleTargetDeath)
+ events.RegisterListener(events.PlayerDeath{}, handleTargetPlayerDeath)
+ events.RegisterListener(events.RoomChange{}, handleTargetRoomChangeNew)
+ events.RegisterListener(events.CombatEnded{}, handleTargetCombatEnded)
+}
+
+func handleCombatTargetUpdate(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(GMCPCombatTargetUpdate)
+ if !typeOk {
+ mudlog.Error("GMCPCombatTarget", "action", "handleCombatTargetUpdate", "error", "type assertion failed", "expectedType", "GMCPCombatTargetUpdate", "actualType", fmt.Sprintf("%T", e))
+ return events.Continue
+ }
+
+ _, valid := validateUserForGMCP(evt.UserId, "GMCPCombatTarget")
+ if !valid {
+ return events.Continue
+ }
+
+ payload := map[string]interface{}{
+ "name": evt.TargetName,
+ }
+
+ if evt.TargetName != "" {
+ payload["hp_current"] = fmt.Sprintf("%d", evt.TargetHpCurrent)
+ payload["hp_max"] = fmt.Sprintf("%d", evt.TargetHpMax)
+ } else {
+ payload["hp_current"] = ""
+ payload["hp_max"] = ""
+ }
+
+ events.AddToQueue(GMCPOut{
+ UserId: evt.UserId,
+ Module: "Char.Combat.Target",
+ Payload: payload,
+ })
+
+ return events.Continue
+}
+
+// handleTargetCombatStarted sets target when player attacks
+func handleTargetCombatStarted(e events.Event) events.ListenerReturn {
+ mudlog.Info("GMCPCombatTarget", "event", "CombatStarted received in Target module")
+ evt, ok := e.(events.CombatStarted)
+ if !ok {
+ mudlog.Error("GMCPCombatTarget", "error", "CombatStarted type assertion failed")
+ return events.Continue
+ }
+
+ mudlog.Info("GMCPCombatTarget", "attackerType", evt.AttackerType, "attackerId", evt.AttackerId,
+ "defenderType", evt.DefenderType, "defenderId", evt.DefenderId)
+
+ // Only care about player attackers
+ if evt.AttackerType != "player" {
+ return events.Continue
+ }
+
+ // Validate user has GMCP
+ _, valid := validateUserForGMCP(evt.AttackerId, "GMCPCombatTarget")
+ if !valid {
+ return events.Continue
+ }
+
+ // Create target info
+ targetInfo := &TargetInfo{
+ Id: evt.DefenderId,
+ Name: util.StripANSI(evt.DefenderName),
+ Type: evt.DefenderType,
+ }
+
+ // Get initial HP if possible
+ if evt.DefenderType == "mob" {
+ if mob := mobs.GetInstance(evt.DefenderId); mob != nil {
+ targetInfo.LastHP = mob.Character.Health
+ targetInfo.LastMaxHP = int(mob.Character.HealthMax.Value)
+ }
+ } else if evt.DefenderType == "player" {
+ if player := users.GetByUserId(evt.DefenderId); player != nil {
+ targetInfo.LastHP = player.Character.Health
+ targetInfo.LastMaxHP = int(player.Character.HealthMax.Value)
+ }
+ }
+
+ // Update tracking
+ targetMutexNew.Lock()
+ userTargetsNew[evt.AttackerId] = targetInfo
+ targetMutexNew.Unlock()
+
+ // Send immediate GMCP update
+ sendTargetUpdateNew(evt.AttackerId)
+
+ mudlog.Info("GMCPCombatTarget", "action", "Target set", "userId", evt.AttackerId,
+ "targetName", targetInfo.Name, "targetType", targetInfo.Type)
+
+ return events.Continue
+}
+
+// handleTargetVitalsChanged updates target HP
+func handleTargetVitalsChanged(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.MobVitalsChanged)
+ if !ok {
+ return events.Continue
+ }
+
+ targetMutexNew.RLock()
+ // Check all users to see who has this mob as target
+ usersToUpdate := []int{}
+ for userId, target := range userTargetsNew {
+ if target.Type == "mob" && target.Id == evt.MobId {
+ usersToUpdate = append(usersToUpdate, userId)
+ }
+ }
+ targetMutexNew.RUnlock()
+
+ // Send updates
+ for _, userId := range usersToUpdate {
+ sendTargetUpdateNew(userId)
+ }
+
+ return events.Continue
+}
+
+// handleTargetDeath clears target when it dies
+func handleTargetDeath(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.MobDeath)
+ if !ok {
+ return events.Continue
+ }
+
+ targetMutexNew.Lock()
+ usersToUpdate := []int{}
+ for userId, target := range userTargetsNew {
+ if target.Type == "mob" && target.Id == evt.InstanceId {
+ delete(userTargetsNew, userId)
+ usersToUpdate = append(usersToUpdate, userId)
+ }
+ }
+ targetMutexNew.Unlock()
+
+ // Send clear target updates
+ for _, userId := range usersToUpdate {
+ handleCombatTargetUpdate(GMCPCombatTargetUpdate{
+ UserId: userId,
+ TargetName: "",
+ })
+ }
+
+ return events.Continue
+}
+
+// handleTargetPlayerDeath handles player target death
+func handleTargetPlayerDeath(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.PlayerDeath)
+ if !ok {
+ return events.Continue
+ }
+
+ targetMutexNew.Lock()
+ usersToUpdate := []int{}
+ for userId, target := range userTargetsNew {
+ if target.Type == "player" && target.Id == evt.UserId {
+ delete(userTargetsNew, userId)
+ usersToUpdate = append(usersToUpdate, userId)
+ }
+ }
+ targetMutexNew.Unlock()
+
+ // Send clear target updates
+ for _, userId := range usersToUpdate {
+ handleCombatTargetUpdate(GMCPCombatTargetUpdate{
+ UserId: userId,
+ TargetName: "",
+ })
+ }
+
+ return events.Continue
+}
+
+// handleTargetRoomChangeNew handles when target moves away
+func handleTargetRoomChangeNew(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.RoomChange)
+ if !ok {
+ return events.Continue
+ }
+
+ // Handle mob targets moving
+ if evt.MobInstanceId != 0 {
+ targetMutexNew.Lock()
+ usersToUpdate := []int{}
+ for userId, target := range userTargetsNew {
+ if target.Type == "mob" && target.Id == evt.MobInstanceId {
+ user := users.GetByUserId(userId)
+ if user != nil && user.Character.RoomId != evt.ToRoomId {
+ delete(userTargetsNew, userId)
+ usersToUpdate = append(usersToUpdate, userId)
+ }
+ }
+ }
+ targetMutexNew.Unlock()
+
+ for _, userId := range usersToUpdate {
+ handleCombatTargetUpdate(GMCPCombatTargetUpdate{
+ UserId: userId,
+ TargetName: "",
+ })
+ }
+ }
+
+ // Handle player targets moving
+ if evt.UserId != 0 {
+ targetMutexNew.Lock()
+ usersToUpdate := []int{}
+ for userId, target := range userTargetsNew {
+ if target.Type == "player" && target.Id == evt.UserId {
+ user := users.GetByUserId(userId)
+ if user != nil && user.Character.RoomId != evt.ToRoomId {
+ delete(userTargetsNew, userId)
+ usersToUpdate = append(usersToUpdate, userId)
+ }
+ }
+ }
+ targetMutexNew.Unlock()
+
+ for _, userId := range usersToUpdate {
+ handleCombatTargetUpdate(GMCPCombatTargetUpdate{
+ UserId: userId,
+ TargetName: "",
+ })
+ }
+ }
+
+ return events.Continue
+}
+
+// handleTargetCombatEnded clears target when combat ends
+func handleTargetCombatEnded(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.CombatEnded)
+ if !ok || evt.EntityType != "player" {
+ return events.Continue
+ }
+
+ targetMutexNew.Lock()
+ delete(userTargetsNew, evt.EntityId)
+ targetMutexNew.Unlock()
+
+ // Clear target
+ handleCombatTargetUpdate(GMCPCombatTargetUpdate{
+ UserId: evt.EntityId,
+ TargetName: "",
+ })
+
+ return events.Continue
+}
+
+// sendTargetUpdateNew sends current target info for a user
+func sendTargetUpdateNew(userId int) {
+ targetMutexNew.RLock()
+ target := userTargetsNew[userId]
+ targetMutexNew.RUnlock()
+
+ if target == nil {
+ return
+ }
+
+ // Get current HP
+ currentHP := 0
+ maxHP := 0
+
+ if target.Type == "mob" {
+ if mob := mobs.GetInstance(target.Id); mob != nil {
+ currentHP = mob.Character.Health
+ maxHP = int(mob.Character.HealthMax.Value)
+ }
+ } else if target.Type == "player" {
+ if player := users.GetByUserId(target.Id); player != nil {
+ currentHP = player.Character.Health
+ maxHP = int(player.Character.HealthMax.Value)
+ }
+ }
+
+ // Only send if HP changed or initial send
+ if currentHP != target.LastHP || target.LastHP == 0 {
+ target.LastHP = currentHP
+ target.LastMaxHP = maxHP
+
+ handleCombatTargetUpdate(GMCPCombatTargetUpdate{
+ UserId: userId,
+ TargetName: target.Name,
+ TargetHpCurrent: currentHP,
+ TargetHpMax: maxHP,
+ })
+ }
+}
+
+// cleanupCombatTargetNew removes all target tracking for a user
+func cleanupCombatTargetNew(userId int) {
+ targetMutexNew.Lock()
+ delete(userTargetsNew, userId)
+ targetMutexNew.Unlock()
+}
diff --git a/modules/gmcp/gmcp.Char.Combat.Target_test.go b/modules/gmcp/gmcp.Char.Combat.Target_test.go
new file mode 100644
index 00000000..3a595b5d
--- /dev/null
+++ b/modules/gmcp/gmcp.Char.Combat.Target_test.go
@@ -0,0 +1,103 @@
+package gmcp
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/GoMudEngine/GoMud/internal/mudlog"
+)
+
+func init() {
+ // Initialize logger for tests
+ mudlog.SetupLogger(nil, "HIGH", "", true)
+}
+
+// TestValidateUserAndLock tests the race condition fix
+func TestValidateUserAndLock(t *testing.T) {
+ // We can't easily test validateUserAndLock directly because it depends on
+ // users.GetByUserId and isGMCPEnabled which we can't mock easily.
+ // Instead, we'll test the cleanup behavior by manipulating the maps directly.
+
+ // Test cleanup behavior
+ targetMutexNew.Lock()
+ userTargetsNew[888] = &TargetInfo{Id: 123, Name: "Test Mob"}
+ targetMutexNew.Unlock()
+
+ // When validateUserAndLock can't find a user, it should clean up
+ // We'll simulate this by calling the cleanup function directly
+ cleanupCombatTargetNew(888)
+
+ // Check cleanup happened
+ targetMutexNew.RLock()
+ if _, exists := userTargetsNew[888]; exists {
+ t.Error("Expected userTargetsNew to be cleaned up")
+ }
+ targetMutexNew.RUnlock()
+}
+
+// TestCombatTargetCleanup tests that cleanup functions work correctly
+func TestCombatTargetCleanup(t *testing.T) {
+ // Set up test data
+ targetMutexNew.Lock()
+ userTargetsNew[100] = &TargetInfo{Id: 200, Name: "Test Target", LastHP: 50}
+ targetMutexNew.Unlock()
+
+ // Call cleanup
+ cleanupCombatTargetNew(100)
+
+ // Verify cleanup
+ targetMutexNew.RLock()
+ if _, exists := userTargetsNew[100]; exists {
+ t.Error("Expected userTargetsNew[100] to be deleted")
+ }
+ targetMutexNew.RUnlock()
+}
+
+// TestConcurrentAccess tests that the maps are safe for concurrent access
+func TestConcurrentAccess(t *testing.T) {
+ var wg sync.WaitGroup
+
+ // Simulate multiple goroutines accessing the maps
+ for i := 0; i < 10; i++ {
+ wg.Add(1)
+ go func(userId int) {
+ defer wg.Done()
+
+ // Write
+ targetMutexNew.Lock()
+ userTargetsNew[userId] = &TargetInfo{Id: userId * 10, Name: "Test", LastHP: userId * 100}
+ targetMutexNew.Unlock()
+
+ // Read
+ targetMutexNew.RLock()
+ _ = userTargetsNew[userId]
+ targetMutexNew.RUnlock()
+
+ // Cleanup
+ cleanupCombatTargetNew(userId)
+ }(i)
+ }
+
+ wg.Wait()
+
+ // Verify all cleaned up
+ targetMutexNew.RLock()
+ if len(userTargetsNew) != 0 {
+ t.Errorf("Expected empty userTargetsNew, got %d entries", len(userTargetsNew))
+ }
+ targetMutexNew.RUnlock()
+}
+
+// TestGMCPCombatTargetUpdateType tests the event type
+func TestGMCPCombatTargetUpdateType(t *testing.T) {
+ update := GMCPCombatTargetUpdate{
+ UserId: 1,
+ TargetName: "TestMob",
+ TargetHpCurrent: 50,
+ TargetHpMax: 100,
+ }
+
+ if update.Type() != "GMCPCombatTargetUpdate" {
+ t.Errorf("Expected Type() to return GMCPCombatTargetUpdate, got %s", update.Type())
+ }
+}
diff --git a/modules/gmcp/gmcp.Char.go b/modules/gmcp/gmcp.Char.go
index 8ca87055..d7c096a5 100644
--- a/modules/gmcp/gmcp.Char.go
+++ b/modules/gmcp/gmcp.Char.go
@@ -4,6 +4,8 @@ import (
"math"
"strconv"
"strings"
+ "sync"
+ "time"
"github.com/GoMudEngine/GoMud/internal/buffs"
"github.com/GoMudEngine/GoMud/internal/configs"
@@ -16,6 +18,7 @@ import (
"github.com/GoMudEngine/GoMud/internal/rooms"
"github.com/GoMudEngine/GoMud/internal/skills"
"github.com/GoMudEngine/GoMud/internal/users"
+ "github.com/GoMudEngine/GoMud/internal/util"
)
// ////////////////////////////////////////////////////////////////////
@@ -40,6 +43,7 @@ func init() {
events.RegisterListener(events.PlayerSpawn{}, g.playerSpawnHandler)
events.RegisterListener(events.CharacterVitalsChanged{}, g.vitalsChangedHandler)
+ events.RegisterListener(events.CharacterAlignmentChanged{}, g.alignmentChangedHandler)
events.RegisterListener(events.LevelUp{}, g.levelUpHandler)
events.RegisterListener(events.CharacterTrained{}, g.charTrainedHandler)
events.RegisterListener(GMCPCharUpdate{}, g.buildAndSendGMCPPayload)
@@ -50,6 +54,9 @@ func init() {
events.RegisterListener(events.Quest{}, g.questProgressHandler)
+ // Clean up tracking maps on player disconnect
+ events.RegisterListener(events.PlayerDespawn{}, g.playerDespawnHandler)
+
}
type GMCPCharModule struct {
@@ -122,6 +129,12 @@ func (g *GMCPCharModule) charChangeHandler(e events.Event) events.ListenerReturn
return events.Continue
}
+// Track last vitals update time to prevent spam
+var (
+ vitalsUpdateMu sync.Mutex
+ lastVitalsUpdate = make(map[int]time.Time)
+)
+
func (g *GMCPCharModule) vitalsChangedHandler(e events.Event) events.ListenerReturn {
evt, typeOk := e.(events.CharacterVitalsChanged)
@@ -133,7 +146,17 @@ func (g *GMCPCharModule) vitalsChangedHandler(e events.Event) events.ListenerRet
return events.Continue
}
- // Changing equipment might affect stats, inventory, maxhp/maxmp etc
+ // Deduplicate rapid vitals updates (max 1 per 100ms)
+ vitalsUpdateMu.Lock()
+ lastUpdate, exists := lastVitalsUpdate[evt.UserId]
+ now := time.Now()
+ if exists && now.Sub(lastUpdate) < 100*time.Millisecond {
+ vitalsUpdateMu.Unlock()
+ return events.Continue // Skip this update
+ }
+ lastVitalsUpdate[evt.UserId] = now
+ vitalsUpdateMu.Unlock()
+
events.AddToQueue(GMCPCharUpdate{
UserId: evt.UserId,
Identifier: `Char.Vitals`,
@@ -142,6 +165,25 @@ func (g *GMCPCharModule) vitalsChangedHandler(e events.Event) events.ListenerRet
return events.Continue
}
+func (g *GMCPCharModule) alignmentChangedHandler(e events.Event) events.ListenerReturn {
+
+ evt, typeOk := e.(events.CharacterAlignmentChanged)
+ if !typeOk {
+ return events.Continue
+ }
+
+ if evt.UserId == 0 {
+ return events.Continue
+ }
+
+ events.AddToQueue(GMCPCharUpdate{
+ UserId: evt.UserId,
+ Identifier: `Char.Info`,
+ })
+
+ return events.Continue
+}
+
func (g *GMCPCharModule) xpGainHandler(e events.Event) events.ListenerReturn {
evt, typeOk := e.(events.GainExperience)
@@ -169,9 +211,14 @@ func (g *GMCPCharModule) ownershipChangeHandler(e events.Event) events.ListenerR
return events.Continue // Return false to stop halt the event chain for this event
}
+ // Send both Items and Summary updates when inventory changes
+ events.AddToQueue(GMCPCharUpdate{
+ UserId: evt.UserId,
+ Identifier: `Char.Inventory.Backpack.Items`,
+ })
events.AddToQueue(GMCPCharUpdate{
UserId: evt.UserId,
- Identifier: `Char.Inventory.Backpack`,
+ Identifier: `Char.Inventory.Backpack.Summary`,
})
return events.Continue
@@ -189,7 +236,9 @@ func (g *GMCPCharModule) statsChangeHandler(e events.Event) events.ListenerRetur
}
// Changing equipment might affect stats, inventory, maxhp/maxmp etc
- events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Stats, Char.Vitals, Char.Inventory.Backpack.Summary`})
+ events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Stats`})
+ events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Vitals`})
+ events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Inventory.Backpack.Summary`})
return events.Continue
}
@@ -205,26 +254,18 @@ func (g *GMCPCharModule) equipmentChangeHandler(e events.Event) events.ListenerR
return events.Continue
}
- statsToChange := ``
-
if len(evt.ItemsRemoved) > 0 || len(evt.ItemsWorn) > 0 {
- statsToChange += `Char.Inventory, Char.Stats, Char.Vitals`
+ // Queue individual events for each module
+ events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Inventory.Worn`})
+ events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Inventory.Backpack.Items`})
+ events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Inventory.Backpack.Summary`})
+ events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Stats`})
+ events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Vitals`})
}
- // If only gold or bank changed
+ // If gold or bank changed
if evt.BankChange != 0 || evt.GoldChange != 0 {
- if statsToChange != `` {
- statsToChange += `, `
- }
- statsToChange += `Char.Worth`
- }
-
- if statsToChange != `` {
- // Changing equipment might affect stats, inventory, maxhp/maxmp etc
- events.AddToQueue(GMCPCharUpdate{
- UserId: evt.UserId,
- Identifier: statsToChange,
- })
+ events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Worth`})
}
return events.Continue
@@ -242,7 +283,10 @@ func (g *GMCPCharModule) charTrainedHandler(e events.Event) events.ListenerRetur
}
// Changing equipment might affect stats, inventory, maxhp/maxmp etc
- events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Stats, Char.Worth, Char.Vitals, Char.Inventory.Backpack.Summary`})
+ events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Stats`})
+ events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Worth`})
+ events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Vitals`})
+ events.AddToQueue(GMCPCharUpdate{UserId: evt.UserId, Identifier: `Char.Inventory.Backpack.Summary`})
return events.Continue
}
@@ -286,6 +330,24 @@ func (g *GMCPCharModule) playerSpawnHandler(e events.Event) events.ListenerRetur
return events.Continue
}
+func (g *GMCPCharModule) playerDespawnHandler(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(events.PlayerDespawn)
+ if !typeOk {
+ return events.Continue
+ }
+
+ if evt.UserId == 0 {
+ return events.Continue
+ }
+
+ // Clean up vitals tracking
+ vitalsUpdateMu.Lock()
+ delete(lastVitalsUpdate, evt.UserId)
+ vitalsUpdateMu.Unlock()
+
+ return events.Continue
+}
+
func (g *GMCPCharModule) buildAndSendGMCPPayload(e events.Event) events.ListenerReturn {
evt, typeOk := e.(GMCPCharUpdate)
@@ -309,55 +371,80 @@ func (g *GMCPCharModule) buildAndSendGMCPPayload(e events.Event) events.Listener
}
if len(evt.Identifier) >= 4 {
+ // Normalize the identifier (handle case variations)
+ identifierParts := strings.Split(strings.ToLower(evt.Identifier), `.`)
+ for i := 0; i < len(identifierParts); i++ {
+ identifierParts[i] = strings.Title(identifierParts[i])
+ }
- for _, identifier := range strings.Split(evt.Identifier, `,`) {
-
- identifier = strings.TrimSpace(identifier)
-
- identifierParts := strings.Split(strings.ToLower(identifier), `.`)
- for i := 0; i < len(identifierParts); i++ {
- identifierParts[i] = strings.Title(identifierParts[i])
- }
-
- requestedId := strings.Join(identifierParts, `.`)
+ requestedId := strings.Join(identifierParts, `.`)
- payload, moduleName := g.GetCharNode(user, requestedId)
+ payload, moduleName := g.GetCharNode(user, requestedId)
+ // Skip if nil payload (handled elsewhere, like sendAllCharNodes)
+ if payload != nil && moduleName != "" {
events.AddToQueue(GMCPOut{
UserId: evt.UserId,
Module: moduleName,
Payload: payload,
})
-
}
-
}
return events.Continue
}
+// sendAllCharNodes sends all Char nodes as individual GMCP messages
+func (g *GMCPCharModule) sendAllCharNodes(user *users.UserRecord) {
+ // Send each node individually
+ events.AddToQueue(GMCPCharUpdate{UserId: user.UserId, Identifier: `Char.Info`})
+ events.AddToQueue(GMCPCharUpdate{UserId: user.UserId, Identifier: `Char.Status`})
+ events.AddToQueue(GMCPCharUpdate{UserId: user.UserId, Identifier: `Char.Stats`})
+ events.AddToQueue(GMCPCharUpdate{UserId: user.UserId, Identifier: `Char.Vitals`})
+ events.AddToQueue(GMCPCharUpdate{UserId: user.UserId, Identifier: `Char.Worth`})
+ events.AddToQueue(GMCPCharUpdate{UserId: user.UserId, Identifier: `Char.Affects`})
+ events.AddToQueue(GMCPCharUpdate{UserId: user.UserId, Identifier: `Char.Inventory.Worn`})
+ events.AddToQueue(GMCPCharUpdate{UserId: user.UserId, Identifier: `Char.Inventory.Backpack.Items`})
+ events.AddToQueue(GMCPCharUpdate{UserId: user.UserId, Identifier: `Char.Inventory.Backpack.Summary`})
+ events.AddToQueue(GMCPCharUpdate{UserId: user.UserId, Identifier: `Char.Quests`})
+ events.AddToQueue(GMCPCharUpdate{UserId: user.UserId, Identifier: `Char.Pets`})
+ events.AddToQueue(GMCPCharUpdate{UserId: user.UserId, Identifier: `Char.Enemies`})
+}
+
func (g *GMCPCharModule) GetCharNode(user *users.UserRecord, gmcpModule string) (data any, moduleName string) {
all := gmcpModule == `Char`
+ // If requesting all, we'll handle it differently by queuing individual messages
+ if all {
+ g.sendAllCharNodes(user)
+ return nil, ""
+ }
+
payload := GMCPCharModule_Payload{}
- if all || g.wantsGMCPPayload(`Char.Info`, gmcpModule) {
+ if g.wantsGMCPPayload(`Char.Info`, gmcpModule) {
payload.Info = &GMCPCharModule_Payload_Info{
Account: user.Username,
- Name: user.Character.Name,
+ Name: util.StripANSI(user.Character.Name),
Class: skills.GetProfession(user.Character.GetAllSkillRanks()),
Race: user.Character.Race(),
Alignment: user.Character.AlignmentName(),
Level: user.Character.Level,
}
- if !all {
- return payload.Info, `Char.Info`
+ return payload.Info, `Char.Info`
+ }
+
+ if g.wantsGMCPPayload(`Char.Status`, gmcpModule) {
+ // Return character status
+ status := map[string]interface{}{
+ "state": "standing",
}
+ return status, `Char.Status`
}
- if all || g.wantsGMCPPayload(`Char.Pets`, gmcpModule) {
+ if g.wantsGMCPPayload(`Char.Pets`, gmcpModule) {
payload.Pets = []GMCPCharModule_Payload_Pet{}
@@ -366,18 +453,16 @@ func (g *GMCPCharModule) GetCharNode(user *users.UserRecord, gmcpModule string)
p := GMCPCharModule_Payload_Pet{
Name: user.Character.Pet.Name,
Type: user.Character.Pet.Type,
- Hunger: `full`,
+ Hunger: user.Character.Pet.Food.String(),
}
payload.Pets = append(payload.Pets, p)
}
- if !all {
- return payload.Pets, `Char.Pets`
- }
+ return payload.Pets, `Char.Pets`
}
- if all || g.wantsGMCPPayload(`Char.Enemies`, gmcpModule) {
+ if g.wantsGMCPPayload(`Char.Enemies`, gmcpModule) {
payload.Enemies = []GMCPCharModule_Enemy{}
@@ -397,12 +482,12 @@ func (g *GMCPCharModule) GetCharNode(user *users.UserRecord, gmcpModule string)
}
e := GMCPCharModule_Enemy{
- Id: mob.ShorthandId(),
- Name: mob.Character.Name,
- Level: mob.Character.Level,
- Hp: mob.Character.Health,
- MaxHp: mob.Character.HealthMax.Value,
- Engaged: mob.InstanceId == aggroMobInstanceId,
+ Id: mob.ShorthandId(),
+ Name: util.StripANSI(mob.Character.Name),
+ Level: mob.Character.Level,
+ Health: mob.Character.Health,
+ HealthMax: mob.Character.HealthMax.Value,
+ Engaged: mob.InstanceId == aggroMobInstanceId,
}
payload.Enemies = append(payload.Enemies, e)
@@ -410,10 +495,7 @@ func (g *GMCPCharModule) GetCharNode(user *users.UserRecord, gmcpModule string)
}
- if !all {
- return payload.Enemies, `Char.Enemies`
- }
-
+ return payload.Enemies, `Char.Enemies`
}
// Allow specifically updating the Backpack Summary
@@ -431,18 +513,18 @@ func (g *GMCPCharModule) GetCharNode(user *users.UserRecord, gmcpModule string)
return payload.Inventory.Backpack.Summary, `Char.Inventory.Backpack.Summary`
}
- if all || g.wantsGMCPPayload(`Char.Inventory`, gmcpModule) || g.wantsGMCPPayload(`Char.Inventory.Backpack`, gmcpModule) {
+ // Allow specifically updating the Backpack Items
+ if `Char.Inventory.Backpack.Items` == gmcpModule {
+ items := []GMCPCharModule_Payload_Inventory_Item{}
+ for _, itm := range user.Character.Items {
+ items = append(items, newInventory_Item(itm))
+ }
+ return items, `Char.Inventory.Backpack.Items`
+ }
+ // Handle individual inventory nodes separately
+ if g.wantsGMCPPayload(`Char.Inventory.Worn`, gmcpModule) {
payload.Inventory = &GMCPCharModule_Payload_Inventory{
-
- Backpack: &GMCPCharModule_Payload_Inventory_Backpack{
- Items: []GMCPCharModule_Payload_Inventory_Item{},
- Summary: GMCPCharModule_Payload_Inventory_Backpack_Summary{
- Count: len(user.Character.Items),
- Max: user.Character.CarryCapacity(),
- },
- },
-
Worn: &GMCPCharModule_Payload_Inventory_Worn{
Weapon: newInventory_Item(user.Character.Equipment.Weapon),
Offhand: newInventory_Item(user.Character.Equipment.Offhand),
@@ -456,23 +538,10 @@ func (g *GMCPCharModule) GetCharNode(user *users.UserRecord, gmcpModule string)
Feet: newInventory_Item(user.Character.Equipment.Feet),
},
}
-
- // Fill the items list
- for _, itm := range user.Character.Items {
- payload.Inventory.Backpack.Items = append(payload.Inventory.Backpack.Items, newInventory_Item(itm))
- }
-
- if !all {
-
- if `Char.Inventory.Backpack` == gmcpModule {
- return payload.Inventory.Backpack, `Char.Inventory.Backpack`
- }
-
- return payload.Inventory, `Char.Inventory`
- }
+ return payload.Inventory.Worn, `Char.Inventory.Worn`
}
- if all || g.wantsGMCPPayload(`Char.Stats`, gmcpModule) {
+ if g.wantsGMCPPayload(`Char.Stats`, gmcpModule) {
payload.Stats = &GMCPCharModule_Payload_Stats{
Strength: user.Character.Stats.Strength.ValueAdj,
@@ -483,42 +552,36 @@ func (g *GMCPCharModule) GetCharNode(user *users.UserRecord, gmcpModule string)
Perception: user.Character.Stats.Perception.ValueAdj,
}
- if !all {
- return payload.Stats, `Char.Stats`
- }
+ return payload.Stats, `Char.Stats`
}
- if all || g.wantsGMCPPayload(`Char.Vitals`, gmcpModule) {
+ if g.wantsGMCPPayload(`Char.Vitals`, gmcpModule) {
payload.Vitals = &GMCPCharModule_Payload_Vitals{
- Hp: user.Character.Health,
- HpMax: user.Character.HealthMax.Value,
- Sp: user.Character.Mana,
- SpMax: user.Character.ManaMax.Value,
+ Health: user.Character.Health,
+ HealthMax: user.Character.HealthMax.Value,
+ SpellPoints: user.Character.Mana,
+ SpellPointsMax: user.Character.ManaMax.Value,
}
- if !all {
- return payload.Vitals, `Char.Vitals`
- }
+ return payload.Vitals, `Char.Vitals`
}
- if all || g.wantsGMCPPayload(`Char.Worth`, gmcpModule) {
+ if g.wantsGMCPPayload(`Char.Worth`, gmcpModule) {
payload.Worth = &GMCPCharModule_Payload_Worth{
- Gold: user.Character.Gold,
- Bank: user.Character.Bank,
+ GoldCarried: user.Character.Gold,
+ GoldBank: user.Character.Bank,
SkillPoints: user.Character.StatPoints,
TrainingPoints: user.Character.TrainingPoints,
- TNL: user.Character.XPTL(user.Character.Level),
- XP: user.Character.Experience,
+ ToNextLevel: user.Character.XPTL(user.Character.Level),
+ Experience: user.Character.Experience,
}
- if !all {
- return payload.Worth, `Char.Worth`
- }
+ return payload.Worth, `Char.Worth`
}
- if all || g.wantsGMCPPayload(`Char.Affects`, gmcpModule) {
+ if g.wantsGMCPPayload(`Char.Affects`, gmcpModule) {
c := configs.GetTimingConfig()
@@ -570,12 +633,10 @@ func (g *GMCPCharModule) GetCharNode(user *users.UserRecord, gmcpModule string)
payload.Affects[name] = aff
}
- if !all {
- return payload.Affects, `Char.Affects`
- }
+ return payload.Affects, `Char.Affects`
}
- if all || g.wantsGMCPPayload(`Char.Quests`, gmcpModule) {
+ if g.wantsGMCPPayload(`Char.Quests`, gmcpModule) {
payload.Quests = []GMCPCharModule_Payload_Quest{}
@@ -616,17 +677,12 @@ func (g *GMCPCharModule) GetCharNode(user *users.UserRecord, gmcpModule string)
payload.Quests = append(payload.Quests, questPayload)
}
- if !all {
- return payload.Quests, `Char.Quests`
- }
- }
-
- // If we reached this point and Char wasn't requested, we have a problem.
- if !all {
- mudlog.Error(`gmcp.Char`, `error`, `Bad module requested`, `module`, gmcpModule)
+ return payload.Quests, `Char.Quests`
}
- return payload, `Char`
+ // If we reached this point, we have a problem.
+ mudlog.Error(`gmcp.Char`, `error`, `Bad module requested`, `module`, gmcpModule)
+ return nil, ""
}
// wantsGMCPPayload(`Char.Info`, `Char`)
@@ -648,91 +704,118 @@ func (g *GMCPCharModule) wantsGMCPPayload(packageToConsider string, packageReque
}
type GMCPCharModule_Payload struct {
- Info *GMCPCharModule_Payload_Info `json:"Info,omitempty"`
- Affects map[string]GMCPCharModule_Payload_Affect `json:"Affects,omitempty"`
- Enemies []GMCPCharModule_Enemy `json:"Enemies,omitempty"`
- Inventory *GMCPCharModule_Payload_Inventory `json:"Inventory,omitempty"`
- Stats *GMCPCharModule_Payload_Stats `json:"Stats,omitempty"`
- Vitals *GMCPCharModule_Payload_Vitals `json:"Vitals,omitempty"`
- Worth *GMCPCharModule_Payload_Worth `json:"Worth,omitempty"`
- Quests []GMCPCharModule_Payload_Quest `json:"Quests,omitempty"`
- Pets []GMCPCharModule_Payload_Pet `json:"Pets,omitempty"`
+ Info *GMCPCharModule_Payload_Info `json:"info"`
+ Affects map[string]GMCPCharModule_Payload_Affect `json:"affects"`
+ Enemies []GMCPCharModule_Enemy `json:"enemies"`
+ Inventory *GMCPCharModule_Payload_Inventory `json:"inventory"`
+ Stats *GMCPCharModule_Payload_Stats `json:"stats"`
+ Vitals *GMCPCharModule_Payload_Vitals `json:"vitals"`
+ Worth *GMCPCharModule_Payload_Worth `json:"worth"`
+ Quests []GMCPCharModule_Payload_Quest `json:"quests"`
+ Pets []GMCPCharModule_Payload_Pet `json:"pets"`
}
// /////////////////
// Char.Info
// /////////////////
type GMCPCharModule_Payload_Info struct {
- Account string `json:"account,omitempty"`
- Name string `json:"name,omitempty"`
- Class string `json:"class,omitempty"`
- Race string `json:"race,omitempty"`
- Alignment string `json:"alignment,omitempty"`
- Level int `json:"level,omitempty"`
+ Account string `json:"account"`
+ Name string `json:"name"`
+ Class string `json:"class"`
+ Race string `json:"race"`
+ Alignment string `json:"alignment"`
+ Level int `json:"level"`
}
// /////////////////
// Char.Enemies
// /////////////////
type GMCPCharModule_Enemy struct {
- Id string `json:"id"`
- Name string `json:"name"`
- Level int `json:"level"`
- Hp int `json:"hp"`
- MaxHp int `json:"hp_max"`
- Engaged bool `json:"engaged"`
+ Id string `json:"id"`
+ Name string `json:"name"`
+ Level int `json:"level"`
+ Health int `json:"health"`
+ HealthMax int `json:"health_max"`
+ Engaged bool `json:"engaged"`
}
// /////////////////
// Char.Inventory
// /////////////////
type GMCPCharModule_Payload_Inventory struct {
- Backpack *GMCPCharModule_Payload_Inventory_Backpack `json:"Backpack,omitempty"`
+ Backpack *GMCPCharModule_Payload_Inventory_Backpack `json:"Backpack"`
Worn *GMCPCharModule_Payload_Inventory_Worn `json:"Worn"`
}
type GMCPCharModule_Payload_Inventory_Backpack struct {
- Items []GMCPCharModule_Payload_Inventory_Item `json:"items,omitempty"`
- Summary GMCPCharModule_Payload_Inventory_Backpack_Summary `json:"Summary,omitempty"`
+ Items []GMCPCharModule_Payload_Inventory_Item `json:"Items"`
+ Summary GMCPCharModule_Payload_Inventory_Backpack_Summary `json:"Summary"`
}
type GMCPCharModule_Payload_Inventory_Backpack_Summary struct {
- Count int `json:"count,omitempty"`
- Max int `json:"max,omitempty"`
+ Count int `json:"count"`
+ Max int `json:"max"`
}
type GMCPCharModule_Payload_Inventory_Worn struct {
- Weapon GMCPCharModule_Payload_Inventory_Item `json:"weapon,omitempty"`
- Offhand GMCPCharModule_Payload_Inventory_Item `json:"offhand,omitempty"`
- Head GMCPCharModule_Payload_Inventory_Item `json:"head,omitempty"`
- Neck GMCPCharModule_Payload_Inventory_Item `json:"neck,omitempty"`
- Body GMCPCharModule_Payload_Inventory_Item `json:"body,omitempty"`
- Belt GMCPCharModule_Payload_Inventory_Item `json:"belt,omitempty"`
- Gloves GMCPCharModule_Payload_Inventory_Item `json:"gloves,omitempty"`
- Ring GMCPCharModule_Payload_Inventory_Item `json:"ring,omitempty"`
- Legs GMCPCharModule_Payload_Inventory_Item `json:"legs,omitempty"`
- Feet GMCPCharModule_Payload_Inventory_Item `json:"feet,omitempty"`
+ Weapon GMCPCharModule_Payload_Inventory_Item `json:"weapon"`
+ Offhand GMCPCharModule_Payload_Inventory_Item `json:"offhand"`
+ Head GMCPCharModule_Payload_Inventory_Item `json:"head"`
+ Neck GMCPCharModule_Payload_Inventory_Item `json:"neck"`
+ Body GMCPCharModule_Payload_Inventory_Item `json:"body"`
+ Belt GMCPCharModule_Payload_Inventory_Item `json:"belt"`
+ Gloves GMCPCharModule_Payload_Inventory_Item `json:"gloves"`
+ Ring GMCPCharModule_Payload_Inventory_Item `json:"ring"`
+ Legs GMCPCharModule_Payload_Inventory_Item `json:"legs"`
+ Feet GMCPCharModule_Payload_Inventory_Item `json:"feet"`
}
type GMCPCharModule_Payload_Inventory_Item struct {
Id string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
- SubType string `json:"subtype"`
+ SubType string `json:"sub_type"`
Uses int `json:"uses"`
Details []string `json:"details"`
+ Command string `json:"command"`
}
func newInventory_Item(itm items.Item) GMCPCharModule_Payload_Inventory_Item {
+ // Handle empty equipment slots
+ if itm.ItemId == 0 {
+ return GMCPCharModule_Payload_Inventory_Item{
+ Id: "",
+ Name: "",
+ Type: "",
+ SubType: "",
+ Uses: 0,
+ Details: []string{},
+ Command: "",
+ }
+ }
+
+ // Handle disabled slots
+ if itm.IsDisabled() {
+ return GMCPCharModule_Payload_Inventory_Item{
+ Id: "",
+ Name: "disabled",
+ Type: "disabled",
+ SubType: "",
+ Uses: 0,
+ Details: []string{"disabled"},
+ Command: "",
+ }
+ }
itmSpec := itm.GetSpec()
d := GMCPCharModule_Payload_Inventory_Item{
Id: itm.ShorthandId(),
- Name: itm.Name(),
+ Name: util.StripANSI(itm.Name()),
Type: string(itmSpec.Type),
SubType: string(itmSpec.Subtype),
Uses: itm.Uses,
Details: []string{},
+ Command: getItemCommand(itmSpec),
}
if !itm.Uncursed && itmSpec.Cursed {
@@ -746,38 +829,79 @@ func newInventory_Item(itm items.Item) GMCPCharModule_Payload_Inventory_Item {
return d
}
+// getItemCommand returns the primary command for using an item based on its type and subtype
+func getItemCommand(spec items.ItemSpec) string {
+ // Check subtype first as it's more specific
+ switch spec.Subtype {
+ case items.Drinkable:
+ return "drink"
+ case items.Edible:
+ return "eat"
+ case items.Usable:
+ return "use"
+ case items.Throwable:
+ return "throw"
+ case items.Wearable:
+ return "wear"
+ }
+
+ // Check type for specific commands
+ switch spec.Type {
+ case items.Weapon:
+ return "wield"
+ case items.Readable:
+ return "read"
+ case items.Grenade:
+ return "drop" // drop to explode
+ case items.Lockpicks:
+ return "picklock"
+ }
+
+ // For worn equipment (already equipped items)
+ if spec.Type == items.Offhand || spec.Type == items.Head ||
+ spec.Type == items.Neck || spec.Type == items.Body ||
+ spec.Type == items.Belt || spec.Type == items.Gloves ||
+ spec.Type == items.Ring || spec.Type == items.Legs ||
+ spec.Type == items.Feet {
+ return "remove"
+ }
+
+ // Default - no specific command
+ return ""
+}
+
// /////////////////
// Char.Stats
// /////////////////
type GMCPCharModule_Payload_Stats struct {
- Strength int `json:"strength,omitempty"`
- Speed int `json:"speed,omitempty"`
- Smarts int `json:"smarts,omitempty"`
- Vitality int `json:"vitality,omitempty"`
- Mysticism int `json:"mysticism,omitempty"`
- Perception int `json:"perception,omitempty"`
+ Strength int `json:"strength"`
+ Speed int `json:"speed"`
+ Smarts int `json:"smarts"`
+ Vitality int `json:"vitality"`
+ Mysticism int `json:"mysticism"`
+ Perception int `json:"perception"`
}
// /////////////////
// Char.Vitals
// /////////////////
type GMCPCharModule_Payload_Vitals struct {
- Hp int `json:"hp,omitempty"`
- HpMax int `json:"hp_max,omitempty"`
- Sp int `json:"sp,omitempty"`
- SpMax int `json:"sp_max,omitempty"`
+ Health int `json:"health"`
+ HealthMax int `json:"health_max"`
+ SpellPoints int `json:"spell_points"`
+ SpellPointsMax int `json:"spell_points_max"`
}
// /////////////////
// Char.Worth
// /////////////////
type GMCPCharModule_Payload_Worth struct {
- Gold int `json:"gold_carry,omitempty"`
- Bank int `json:"gold_bank,omitempty"`
- SkillPoints int `json:"skillpoints,omitempty"`
- TrainingPoints int `json:"trainingpoints,omitempty"`
- TNL int `json:"tnl,omitempty"`
- XP int `json:"xp,omitempty"`
+ GoldCarried int `json:"gold_carried"`
+ GoldBank int `json:"gold_bank"`
+ SkillPoints int `json:"skill_points"`
+ TrainingPoints int `json:"training_points"`
+ ToNextLevel int `json:"to_next_level"`
+ Experience int `json:"experience"`
}
// /////////////////
@@ -787,7 +911,7 @@ type GMCPCharModule_Payload_Affect struct {
Name string `json:"name"`
Description string `json:"description"`
DurationMax int `json:"duration_max"`
- DurationLeft int `json:"duration_cur"`
+ DurationLeft int `json:"duration_current"`
Type string `json:"type"`
Mods map[string]int `json:"affects"`
}
diff --git a/modules/gmcp/gmcp.Game.go b/modules/gmcp/gmcp.Game.go
index 3d14336d..df475ed9 100644
--- a/modules/gmcp/gmcp.Game.go
+++ b/modules/gmcp/gmcp.Game.go
@@ -1,8 +1,6 @@
package gmcp
import (
- "strconv"
-
"github.com/GoMudEngine/GoMud/internal/configs"
"github.com/GoMudEngine/GoMud/internal/events"
"github.com/GoMudEngine/GoMud/internal/plugins"
@@ -28,6 +26,7 @@ func init() {
events.RegisterListener(events.PlayerDespawn{}, g.onJoinLeave)
events.RegisterListener(events.PlayerSpawn{}, g.onJoinLeave)
+ events.RegisterListener(GMCPGameUpdate{}, g.buildAndSendGMCPPayload)
}
@@ -36,37 +35,125 @@ type GMCPGameModule struct {
plug *plugins.Plugin
}
+// GMCPGameUpdate is used to request Game module updates
+type GMCPGameUpdate struct {
+ UserId int
+ Identifier string
+}
+
+func (g GMCPGameUpdate) Type() string { return `GMCPGameUpdate` }
+
func (g *GMCPGameModule) onJoinLeave(e events.Event) events.ListenerReturn {
- c := configs.GetConfig()
+ // Handle PlayerSpawn - send Game modules to the spawning player
+ if spawnEvt, isSpawn := e.(events.PlayerSpawn); isSpawn {
+ if spawnEvt.UserId == 0 {
+ return events.Continue
+ }
- tFormat := string(c.TextFormats.Time)
+ // Don't send Game modules here - SendFullGMCPUpdate handles it
+ // This prevents duplicate sending and potential race conditions
+ // g.sendAllGameNodes(spawnEvt.UserId)
+ }
- whoPayload := `"Who": { "Players": [`
+ // For both spawn and despawn, update Game.Who for all active users
+ // since the player list has changed
+ players := []map[string]interface{}{}
- infoPayloads := map[int]string{}
+ for _, user := range users.GetAllActiveUsers() {
+ players = append(players, map[string]interface{}{
+ "level": user.Character.Level,
+ "name": user.Character.Name,
+ "title": user.Role,
+ })
+ }
- pCt := 0
+ // Send updated Game.Who.Players to all active users
for _, user := range users.GetAllActiveUsers() {
+ events.AddToQueue(GMCPOut{
+ UserId: user.UserId,
+ Module: `Game.Who.Players`,
+ Payload: players,
+ })
+ }
- infoPayloads[user.UserId] = `"Info": { "logintime": "` + user.GetConnectTime().Format(tFormat) + `", "name": "` + string(c.Server.MudName) + `" }`
+ return events.Continue
+}
- if pCt > 0 {
- whoPayload += `, `
- }
- pCt++
+func (g *GMCPGameModule) buildAndSendGMCPPayload(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(GMCPGameUpdate)
+ if !typeOk {
+ return events.Continue
+ }
- whoPayload += `{ "level": ` + strconv.Itoa(user.Character.Level) + `, "name": "` + user.Character.Name + `", "title": "` + user.Role + `"}`
+ if evt.UserId < 1 {
+ return events.Continue
}
- whoPayload += `] }`
- for userId, infoStr := range infoPayloads {
- events.AddToQueue(GMCPOut{
- UserId: userId,
- Module: `Game`,
- Payload: `{ ` + infoStr + `, ` + whoPayload + ` }`,
- })
+ // Make sure they have GMCP enabled
+ user := users.GetByUserId(evt.UserId)
+ if user == nil {
+ return events.Continue
}
+ if !isGMCPEnabled(user.ConnectionId()) {
+ return events.Continue
+ }
+
+ // If requesting "Game", send all sub-nodes
+ if evt.Identifier == `Game` {
+ g.sendAllGameNodes(evt.UserId)
+ return events.Continue
+ }
+
+ // Otherwise, send the specific node requested (not implemented for now)
+ // Individual Game sub-nodes could be added here if needed
+
return events.Continue
}
+
+// sendAllGameNodes sends all Game nodes as individual GMCP messages
+func (g *GMCPGameModule) sendAllGameNodes(userId int) {
+ user := users.GetByUserId(userId)
+ if user == nil {
+ return
+ }
+
+ if !isGMCPEnabled(user.ConnectionId()) {
+ return
+ }
+
+ c := configs.GetConfig()
+ tFormat := string(c.UserInterface.Formats.Time)
+
+ // Send Game.Info
+ infoPayload := map[string]interface{}{
+ "engine": "GoMud",
+ "login_time": user.GetConnectTime().Format(tFormat),
+ "login_time_epoch": user.GetConnectTime().Unix(),
+ "name": string(c.Server.MudName),
+ }
+
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Game.Info`,
+ Payload: infoPayload,
+ })
+
+ // Build and send Game.Who.Players
+ players := []map[string]interface{}{}
+
+ for _, u := range users.GetAllActiveUsers() {
+ players = append(players, map[string]interface{}{
+ "level": u.Character.Level,
+ "name": u.Character.Name,
+ "title": u.Role,
+ })
+ }
+
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Game.Who.Players`,
+ Payload: players,
+ })
+}
diff --git a/modules/gmcp/gmcp.Mudlet.go b/modules/gmcp/gmcp.Mudlet.go
index fab974b8..606687d0 100644
--- a/modules/gmcp/gmcp.Mudlet.go
+++ b/modules/gmcp/gmcp.Mudlet.go
@@ -1,7 +1,6 @@
package gmcp
import (
- "embed"
"fmt"
"strings"
@@ -9,17 +8,11 @@ import (
"github.com/GoMudEngine/GoMud/internal/events"
"github.com/GoMudEngine/GoMud/internal/mudlog"
"github.com/GoMudEngine/GoMud/internal/parties"
- "github.com/GoMudEngine/GoMud/internal/plugins"
"github.com/GoMudEngine/GoMud/internal/rooms"
"github.com/GoMudEngine/GoMud/internal/usercommands"
"github.com/GoMudEngine/GoMud/internal/users"
)
-var (
- //go:embed files/*
- files embed.FS
-)
-
// MudletConfig holds the configuration for Mudlet clients
type MudletConfig struct {
// Mapper configuration
@@ -43,9 +36,8 @@ type MudletConfig struct {
DiscordSmallImageKey string `json:"discord_small_image_key" yaml:"discord_small_image_key"`
}
-// GMCPMudletModule handles Mudlet-specific GMCP functionality
-type GMCPMudletModule struct {
- plug *plugins.Plugin
+// GMCPMudletHandler handles Mudlet-specific GMCP functionality
+type GMCPMudletHandler struct {
config MudletConfig
mudletUsers map[int]bool // Track which users are using Mudlet clients
}
@@ -74,70 +66,42 @@ type GMCPDiscordMessage struct {
func (g GMCPDiscordMessage) Type() string { return `GMCPDiscordMessage` }
-func init() {
- // Create module with basic structure
- g := GMCPMudletModule{
- plug: plugins.New(`gmcp.Mudlet`, `1.0`),
- mudletUsers: make(map[int]bool),
- }
+// mudletHandler is the global instance for Mudlet-specific functionality
+var mudletHandler *GMCPMudletHandler
- // Attach filesystem with proper error handling
- if err := g.plug.AttachFileSystem(files); err != nil {
- panic(err)
+// initMudlet initializes the Mudlet handler - called from main gmcp.go init
+func initMudlet() {
+ // Create handler with basic structure
+ mudletHandler = &GMCPMudletHandler{
+ mudletUsers: make(map[int]bool),
}
- // Register callbacks for load/save
- g.plug.Callbacks.SetOnLoad(g.load)
- g.plug.Callbacks.SetOnSave(g.save)
-
// Register event listeners
- events.RegisterListener(events.PlayerSpawn{}, g.playerSpawnHandler)
- events.RegisterListener(events.PlayerDespawn{}, g.playerDespawnHandler)
- events.RegisterListener(GMCPMudletDetected{}, g.mudletDetectedHandler)
- events.RegisterListener(GMCPDiscordStatusRequest{}, g.discordStatusRequestHandler)
- events.RegisterListener(GMCPDiscordMessage{}, g.discordMessageHandler)
- events.RegisterListener(events.RoomChange{}, g.roomChangeHandler)
- events.RegisterListener(events.PartyUpdated{}, g.partyUpdateHandler)
-
- // Register the Mudlet-specific user commands
- g.plug.AddUserCommand("mudletmap", g.sendMapCommand, true, false)
- g.plug.AddUserCommand("mudletui", g.sendUICommand, false, false)
- g.plug.AddUserCommand("checkclient", g.checkClientCommand, true, false)
- g.plug.AddUserCommand("discord", g.discordCommand, true, false)
-}
-
-// Helper function to load a config string from the plugin's configuration
-func loadConfigString(p *plugins.Plugin, key string) string {
- if val, ok := p.Config.Get(key).(string); ok {
+ events.RegisterListener(events.PlayerSpawn{}, mudletHandler.playerSpawnHandler)
+ events.RegisterListener(events.PlayerDespawn{}, mudletHandler.playerDespawnHandler)
+ events.RegisterListener(GMCPMudletDetected{}, mudletHandler.mudletDetectedHandler)
+ events.RegisterListener(GMCPDiscordStatusRequest{}, mudletHandler.discordStatusRequestHandler)
+ events.RegisterListener(GMCPDiscordMessage{}, mudletHandler.discordMessageHandler)
+ events.RegisterListener(events.RoomChange{}, mudletHandler.roomChangeHandler)
+ events.RegisterListener(events.PartyUpdated{}, mudletHandler.partyUpdateHandler)
+
+ // Register the Mudlet-specific user commands through the main GMCP plugin
+ gmcpModule.plug.AddUserCommand("mudletmap", mudletHandler.sendMapCommand, true, false)
+ gmcpModule.plug.AddUserCommand("mudletui", mudletHandler.sendUICommand, false, false)
+ gmcpModule.plug.AddUserCommand("checkclient", mudletHandler.checkClientCommand, true, false)
+ gmcpModule.plug.AddUserCommand("discord", mudletHandler.discordCommand, true, false)
+}
+
+// Helper function to get a config string from the main GMCP plugin
+func getConfigString(key string) string {
+ if val, ok := gmcpModule.plug.Config.Get(key).(string); ok {
return val
}
return ""
}
-// load handles loading configuration from the plugin's storage
-func (g *GMCPMudletModule) load() {
- // Load config values directly from embedded config or overrides
- g.config.MapperVersion = loadConfigString(g.plug, "mapper_version")
- g.config.MapperURL = loadConfigString(g.plug, "mapper_url")
- g.config.UIVersion = loadConfigString(g.plug, "ui_version")
- g.config.UIURL = loadConfigString(g.plug, "ui_url")
- g.config.MapVersion = loadConfigString(g.plug, "map_version")
- g.config.MapURL = loadConfigString(g.plug, "map_url")
- g.config.DiscordApplicationID = loadConfigString(g.plug, "discord_application_id")
- g.config.DiscordInviteURL = loadConfigString(g.plug, "discord_invite_url")
- g.config.DiscordLargeImageKey = loadConfigString(g.plug, "discord_large_image_key")
- g.config.DiscordDetails = loadConfigString(g.plug, "discord_details")
- g.config.DiscordState = loadConfigString(g.plug, "discord_state")
- g.config.DiscordSmallImageKey = loadConfigString(g.plug, "discord_small_image_key")
-}
-
-// save handles saving configuration to the plugin's storage
-func (g *GMCPMudletModule) save() {
- g.plug.WriteStruct(`mudlet_config`, g.config)
-}
-
// Helper function to check if a user is using a Mudlet client
-func (g *GMCPMudletModule) isMudletClient(userId int) bool {
+func (g *GMCPMudletHandler) isMudletClient(userId int) bool {
if userId < 1 {
return false
}
@@ -188,7 +152,7 @@ func sendGMCP(userId int, module string, payload interface{}) {
}
// Helper function to create and send Discord Info message
-func (g *GMCPMudletModule) sendDiscordInfo(userId int) {
+func (g *GMCPMudletHandler) sendDiscordInfo(userId int) {
if userId < 1 {
return
}
@@ -204,13 +168,14 @@ func (g *GMCPMudletModule) sendDiscordInfo(userId int) {
return
}
+ // Read config values dynamically to get latest overrides
// Send Discord Info payload
payload := struct {
ApplicationID string `json:"applicationid"`
InviteURL string `json:"inviteurl"`
}{
- ApplicationID: g.config.DiscordApplicationID,
- InviteURL: g.config.DiscordInviteURL,
+ ApplicationID: getConfigString("discord_application_id"),
+ InviteURL: getConfigString("discord_invite_url"),
}
sendGMCP(userId, "External.Discord.Info", payload)
@@ -218,7 +183,7 @@ func (g *GMCPMudletModule) sendDiscordInfo(userId int) {
}
// sendDiscordStatus sends the current Discord status information
-func (g *GMCPMudletModule) sendDiscordStatus(userId int) {
+func (g *GMCPMudletHandler) sendDiscordStatus(userId int) {
if userId < 1 {
return
}
@@ -249,8 +214,9 @@ func (g *GMCPMudletModule) sendDiscordStatus(userId int) {
showName := getUserBoolOption(user, "discord_show_name", true)
showLevel := getUserBoolOption(user, "discord_show_level", true)
+ // Read config values dynamically to get latest overrides
// Build the details string based on preferences
- detailsStr := g.config.DiscordDetails
+ detailsStr := getConfigString("discord_details")
if showName || showLevel {
detailsStr = ""
if showName {
@@ -280,10 +246,10 @@ func (g *GMCPMudletModule) sendDiscordStatus(userId int) {
PartyMax int `json:"partymax,omitempty"`
}{
Details: detailsStr,
- State: g.config.DiscordState,
+ State: getConfigString("discord_state"),
Game: configs.GetServerConfig().MudName.String(),
- LargeImageKey: g.config.DiscordLargeImageKey,
- SmallImageKey: g.config.DiscordSmallImageKey,
+ LargeImageKey: getConfigString("discord_large_image_key"),
+ SmallImageKey: getConfigString("discord_small_image_key"),
StartTime: user.GetConnectTime().Unix(),
}
@@ -309,7 +275,7 @@ func (g *GMCPMudletModule) sendDiscordStatus(userId int) {
}
// Send empty Discord status to clear it
-func (g *GMCPMudletModule) clearDiscordStatus(userId int) {
+func (g *GMCPMudletHandler) clearDiscordStatus(userId int) {
payload := struct {
Details string `json:"details"`
State string `json:"state"`
@@ -328,31 +294,52 @@ func (g *GMCPMudletModule) clearDiscordStatus(userId int) {
}
// Send Mudlet map configuration
-func (g *GMCPMudletModule) sendMudletMapConfig(userId int) {
+func (g *GMCPMudletHandler) sendMudletMapConfig(userId int) {
if userId < 1 {
return
}
+ // Read config values dynamically to get latest overrides
mapConfig := map[string]string{
- "url": g.config.MapURL,
+ "url": getConfigString("map_url"),
}
sendGMCP(userId, "Client.Map", mapConfig)
mudlog.Debug("GMCP", "type", "Mudlet", "action", "Sent Mudlet map config", "userId", userId)
}
+// Send Mudlet mapper package installation message
+func (g *GMCPMudletHandler) sendMudletMapperInstall(userId int) {
+ if userId < 1 {
+ return
+ }
+
+ // Read config values dynamically to get latest overrides
+ payload := struct {
+ Version string `json:"version"`
+ URL string `json:"url"`
+ }{
+ Version: getConfigString("mapper_version"),
+ URL: getConfigString("mapper_url"),
+ }
+
+ sendGMCP(userId, "Client.GUI", payload)
+ mudlog.Debug("GMCP", "type", "Mudlet", "action", "Sent Mudlet mapper install config", "userId", userId)
+}
+
// Send Mudlet UI package installation message
-func (g *GMCPMudletModule) sendMudletUIInstall(userId int) {
+func (g *GMCPMudletHandler) sendMudletUIInstall(userId int) {
if userId < 1 {
return
}
+ // Read config values dynamically to get latest overrides
payload := struct {
Version string `json:"version"`
URL string `json:"url"`
}{
- Version: g.config.UIVersion,
- URL: g.config.UIURL,
+ Version: getConfigString("ui_version"),
+ URL: getConfigString("ui_url"),
}
sendGMCP(userId, "Client.GUI", payload)
@@ -360,7 +347,7 @@ func (g *GMCPMudletModule) sendMudletUIInstall(userId int) {
}
// Send Mudlet UI package removal message
-func (g *GMCPMudletModule) sendMudletUIRemove(userId int) {
+func (g *GMCPMudletHandler) sendMudletUIRemove(userId int) {
if userId < 1 {
return
}
@@ -376,7 +363,7 @@ func (g *GMCPMudletModule) sendMudletUIRemove(userId int) {
}
// Send Mudlet UI package update message
-func (g *GMCPMudletModule) sendMudletUIUpdate(userId int) {
+func (g *GMCPMudletHandler) sendMudletUIUpdate(userId int) {
if userId < 1 {
return
}
@@ -392,26 +379,16 @@ func (g *GMCPMudletModule) sendMudletUIUpdate(userId int) {
}
// Send mapper configuration to Mudlet client
-func (g *GMCPMudletModule) sendMudletConfig(userId int) {
+func (g *GMCPMudletHandler) sendMudletConfig(userId int) {
if userId < 1 {
return
}
- // Send mapper info
- payload := struct {
- Version string `json:"version"`
- URL string `json:"url"`
- }{
- Version: g.config.MapperVersion,
- URL: g.config.MapperURL,
- }
- sendGMCP(userId, "Client.GUI", payload)
+ // Send Client.Map first (before Client.GUI)
+ g.sendMudletMapConfig(userId)
- // Get the user record
- user := users.GetByUserId(userId)
- if user == nil {
- return
- }
+ // Then send Client.GUI with mapper package installation info
+ g.sendMudletMapperInstall(userId)
// Send Discord info if enabled
g.sendDiscordInfo(userId)
@@ -423,7 +400,7 @@ func (g *GMCPMudletModule) sendMudletConfig(userId int) {
}
// playerSpawnHandler handles when a player connects
-func (g *GMCPMudletModule) playerSpawnHandler(e events.Event) events.ListenerReturn {
+func (g *GMCPMudletHandler) playerSpawnHandler(e events.Event) events.ListenerReturn {
evt, typeOk := e.(events.PlayerSpawn)
if !typeOk {
mudlog.Error("Event", "Expected Type", "PlayerSpawn", "Actual Type", e.Type())
@@ -440,7 +417,7 @@ func (g *GMCPMudletModule) playerSpawnHandler(e events.Event) events.ListenerRet
}
// playerDespawnHandler handles when a player disconnects
-func (g *GMCPMudletModule) playerDespawnHandler(e events.Event) events.ListenerReturn {
+func (g *GMCPMudletHandler) playerDespawnHandler(e events.Event) events.ListenerReturn {
evt, typeOk := e.(events.PlayerDespawn)
if !typeOk {
mudlog.Error("Event", "Expected Type", "PlayerDespawn", "Actual Type", e.Type())
@@ -457,7 +434,7 @@ func (g *GMCPMudletModule) playerDespawnHandler(e events.Event) events.ListenerR
}
// mudletDetectedHandler handles when a Mudlet client is detected
-func (g *GMCPMudletModule) mudletDetectedHandler(e events.Event) events.ListenerReturn {
+func (g *GMCPMudletHandler) mudletDetectedHandler(e events.Event) events.ListenerReturn {
evt, typeOk := e.(GMCPMudletDetected)
if !typeOk {
mudlog.Error("Event", "Expected Type", "GMCPMudletDetected", "Actual Type", e.Type())
@@ -472,7 +449,7 @@ func (g *GMCPMudletModule) mudletDetectedHandler(e events.Event) events.Listener
}
// discordStatusRequestHandler handles Discord status requests
-func (g *GMCPMudletModule) discordStatusRequestHandler(e events.Event) events.ListenerReturn {
+func (g *GMCPMudletHandler) discordStatusRequestHandler(e events.Event) events.ListenerReturn {
evt, typeOk := e.(GMCPDiscordStatusRequest)
if !typeOk {
mudlog.Error("Event", "Expected Type", "GMCPDiscordStatusRequest", "Actual Type", e.Type())
@@ -488,7 +465,7 @@ func (g *GMCPMudletModule) discordStatusRequestHandler(e events.Event) events.Li
}
// discordMessageHandler handles Discord-related GMCP messages
-func (g *GMCPMudletModule) discordMessageHandler(e events.Event) events.ListenerReturn {
+func (g *GMCPMudletHandler) discordMessageHandler(e events.Event) events.ListenerReturn {
evt, typeOk := e.(GMCPDiscordMessage)
if !typeOk {
mudlog.Error("Event", "Expected Type", "GMCPDiscordMessage", "Actual Type", e.Type())
@@ -528,7 +505,7 @@ func (g *GMCPMudletModule) discordMessageHandler(e events.Event) events.Listener
}
// roomChangeHandler updates Discord status when players change areas
-func (g *GMCPMudletModule) roomChangeHandler(e events.Event) events.ListenerReturn {
+func (g *GMCPMudletHandler) roomChangeHandler(e events.Event) events.ListenerReturn {
evt, typeOk := e.(events.RoomChange)
if !typeOk {
return events.Cancel
@@ -560,7 +537,7 @@ func (g *GMCPMudletModule) roomChangeHandler(e events.Event) events.ListenerRetu
}
// partyUpdateHandler updates Discord status for party members
-func (g *GMCPMudletModule) partyUpdateHandler(e events.Event) events.ListenerReturn {
+func (g *GMCPMudletHandler) partyUpdateHandler(e events.Event) events.ListenerReturn {
evt, typeOk := e.(events.PartyUpdated)
if !typeOk {
mudlog.Error("Event", "Expected Type", "PartyUpdated", "Actual Type", e.Type())
@@ -578,7 +555,7 @@ func (g *GMCPMudletModule) partyUpdateHandler(e events.Event) events.ListenerRet
}
// Helper function for handling command toggles
-func (g *GMCPMudletModule) handleToggleCommand(user *users.UserRecord, settingName string, value bool, enableMsg string, disableMsg string) {
+func (g *GMCPMudletHandler) handleToggleCommand(user *users.UserRecord, settingName string, value bool, enableMsg string, disableMsg string) {
user.SetConfigOption(settingName, value)
if value {
user.SendText("\n
" + enableMsg + "\n")
@@ -593,7 +570,7 @@ func (g *GMCPMudletModule) handleToggleCommand(user *users.UserRecord, settingNa
}
// sendUICommand handles UI-related commands
-func (g *GMCPMudletModule) sendUICommand(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) {
+func (g *GMCPMudletHandler) sendUICommand(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) {
// Only proceed if client is Mudlet
connId := user.ConnectionId()
if gmcpData, ok := gmcpModule.cache.Get(connId); !ok || !gmcpData.Client.IsMudlet {
@@ -663,7 +640,7 @@ func (g *GMCPMudletModule) sendUICommand(rest string, user *users.UserRecord, ro
}
// sendMapCommand sends map configuration
-func (g *GMCPMudletModule) sendMapCommand(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) {
+func (g *GMCPMudletHandler) sendMapCommand(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) {
// Only send if the client is Mudlet
connId := user.ConnectionId()
if gmcpData, ok := gmcpModule.cache.Get(connId); ok && gmcpData.Client.IsMudlet {
@@ -674,7 +651,7 @@ func (g *GMCPMudletModule) sendMapCommand(rest string, user *users.UserRecord, r
}
// checkClientCommand checks if client is Mudlet and shows info
-func (g *GMCPMudletModule) checkClientCommand(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) {
+func (g *GMCPMudletHandler) checkClientCommand(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) {
// Check if client is Mudlet
connId := user.ConnectionId()
if gmcpData, ok := gmcpModule.cache.Get(connId); ok && gmcpData.Client.IsMudlet {
@@ -691,7 +668,7 @@ func (g *GMCPMudletModule) checkClientCommand(rest string, user *users.UserRecor
}
// discordCommand handles Discord-related settings
-func (g *GMCPMudletModule) discordCommand(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) {
+func (g *GMCPMudletHandler) discordCommand(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) {
// Only proceed if client is Mudlet
connId := user.ConnectionId()
if gmcpData, ok := gmcpModule.cache.Get(connId); !ok || !gmcpData.Client.IsMudlet {
diff --git a/modules/gmcp/gmcp.Party.go b/modules/gmcp/gmcp.Party.go
index b6031940..4abccf90 100644
--- a/modules/gmcp/gmcp.Party.go
+++ b/modules/gmcp/gmcp.Party.go
@@ -26,12 +26,13 @@ func init() {
// how to use a struct
//
g := GMCPPartyModule{
- plug: plugins.New(`gmcp.Comm`, `1.0`),
+ plug: plugins.New(`gmcp.Party`, `1.0`),
}
events.RegisterListener(events.RoomChange{}, g.roomChangeHandler)
events.RegisterListener(events.PartyUpdated{}, g.onPartyChange)
events.RegisterListener(PartyUpdateVitals{}, g.onUpdateVitals)
+ events.RegisterListener(GMCPPartyUpdate{}, g.buildAndSendGMCPPayload)
}
@@ -40,6 +41,14 @@ type GMCPPartyModule struct {
plug *plugins.Plugin
}
+// GMCPPartyUpdate is used to request Party module updates
+type GMCPPartyUpdate struct {
+ UserId int
+ Identifier string
+}
+
+func (g GMCPPartyUpdate) Type() string { return `GMCPPartyUpdate` }
+
// This is a uniqu event so that multiple party members moving thorugh an area all at once don't queue up a bunch for just one party
type PartyUpdateVitals struct {
LeaderId int
@@ -119,7 +128,9 @@ func (g *GMCPPartyModule) onPartyChange(e events.Event) events.ListenerReturn {
}
}
- payload, moduleName := g.GetPartyNode(party, `Party`)
+ // Send both Party.Info and Party.Vitals as separate messages
+ infoPayload, _ := g.GetPartyNode(party, `Party.Info`)
+ vitalsPayload, _ := g.GetPartyNode(party, `Party.Vitals`)
inParty := map[int]struct{}{}
if party != nil {
@@ -134,19 +145,32 @@ func (g *GMCPPartyModule) onPartyChange(e events.Event) events.ListenerReturn {
for _, userId := range evt.UserIds {
if _, ok := inParty[userId]; ok {
+ // Send party info (structure)
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Party.Info`,
+ Payload: infoPayload,
+ })
+ // Send party vitals (health/location)
events.AddToQueue(GMCPOut{
UserId: userId,
- Module: moduleName,
- Payload: payload,
+ Module: `Party.Vitals`,
+ Payload: vitalsPayload,
})
} else {
+ // Not in party - send empty payloads
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Party.Info`,
+ Payload: GMCPPartyModule_Payload_Info{},
+ })
events.AddToQueue(GMCPOut{
UserId: userId,
- Module: `Party`,
- Payload: GMCPPartyModule_Payload{},
+ Module: `Party.Vitals`,
+ Payload: map[string]GMCPPartyModule_Payload_Vitals{},
})
}
@@ -158,19 +182,22 @@ func (g *GMCPPartyModule) onPartyChange(e events.Event) events.ListenerReturn {
func (g *GMCPPartyModule) GetPartyNode(party *parties.Party, gmcpModule string) (data any, moduleName string) {
- all := gmcpModule == `Party`
-
if party == nil {
- return GMCPPartyModule_Payload_Vitals{}, `Party`
+ if gmcpModule == `Party.Info` {
+ return GMCPPartyModule_Payload_Info{}, `Party.Info`
+ }
+ return map[string]GMCPPartyModule_Payload_Vitals{}, `Party.Vitals`
}
- partyPayload := GMCPPartyModule_Payload{
+ // Prepare both info and vitals data
+ infoPayload := GMCPPartyModule_Payload_Info{
Leader: `None`,
Members: []GMCPPartyModule_Payload_User{},
Invited: []GMCPPartyModule_Payload_User{},
- Vitals: map[string]GMCPPartyModule_Payload_Vitals{},
}
+ vitalsPayload := map[string]GMCPPartyModule_Payload_Vitals{}
+
roomTitles := map[int]string{}
for _, uId := range party.GetMembers() {
@@ -190,27 +217,31 @@ func (g *GMCPPartyModule) GetPartyNode(party *parties.Party, gmcpModule string)
}
}
- partyPayload.Vitals[user.Character.Name] = GMCPPartyModule_Payload_Vitals{
+ vitalsPayload[user.Character.Name] = GMCPPartyModule_Payload_Vitals{
Level: user.Character.Level,
HealthPercent: hPct,
Location: roomTitle,
}
- if gmcpModule == `Party.Vitals` {
- continue
- }
+ // Only add to info payload if we're requesting Party.Info
+ if gmcpModule == `Party.Info` {
+ if user.UserId == party.LeaderUserId {
+ infoPayload.Leader = user.Character.Name
+ }
- if user.UserId == party.LeaderUserId {
- partyPayload.Leader = user.Character.Name
- }
+ status := "party"
+ if user.UserId == party.LeaderUserId {
+ status = "leader"
+ }
- partyPayload.Members = append(partyPayload.Members,
- GMCPPartyModule_Payload_User{
- Name: user.Character.Name,
- Status: `In Party`,
- Position: party.GetRank(user.UserId),
- },
- )
+ infoPayload.Members = append(infoPayload.Members,
+ GMCPPartyModule_Payload_User{
+ Name: user.Character.Name,
+ Status: status,
+ Position: party.GetRank(user.UserId),
+ },
+ )
+ }
}
@@ -220,41 +251,48 @@ func (g *GMCPPartyModule) GetPartyNode(party *parties.Party, gmcpModule string)
if user := users.GetByUserId(uId); user != nil {
- partyPayload.Vitals[user.Character.Name] = GMCPPartyModule_Payload_Vitals{
+ // Invited users show as empty vitals
+ vitalsPayload[user.Character.Name] = GMCPPartyModule_Payload_Vitals{
Level: 0,
HealthPercent: 0,
Location: ``,
}
- if gmcpModule == `Party.Vitals` {
- continue
+ // Only add to info payload if we're requesting Party.Info
+ if gmcpModule == `Party.Info` {
+ infoPayload.Invited = append(infoPayload.Invited,
+ GMCPPartyModule_Payload_User{
+ Name: user.Character.Name,
+ Status: `invited`,
+ Position: ``,
+ },
+ )
}
- partyPayload.Invited = append(partyPayload.Invited,
- GMCPPartyModule_Payload_User{
- Name: user.Character.Name,
- Status: `Invited`,
- Position: ``,
- },
- )
-
}
}
- if gmcpModule == `Party.Vitals` {
- return partyPayload.Vitals, `Party.Vitals`
- }
-
- // If we reached this point and Char wasn't requested, we have a problem.
- if !all {
- mudlog.Error(`gmcp.Room`, `error`, `Bad module requested`, `module`, gmcpModule)
+ switch gmcpModule {
+ case `Party.Info`:
+ return infoPayload, `Party.Info`
+ case `Party.Vitals`:
+ return vitalsPayload, `Party.Vitals`
+ default:
+ mudlog.Error(`gmcp.Party`, `error`, `Bad module requested`, `module`, gmcpModule)
+ return nil, ""
}
- return partyPayload, `Party`
+}
+// GMCPPartyModule_Payload_Info contains static party structure
+type GMCPPartyModule_Payload_Info struct {
+ Leader string `json:"leader"`
+ Members []GMCPPartyModule_Payload_User `json:"members"`
+ Invited []GMCPPartyModule_Payload_User `json:"invited"`
}
+// DEPRECATED: Old combined payload structure
type GMCPPartyModule_Payload struct {
Leader string
Members []GMCPPartyModule_Payload_User
@@ -273,3 +311,69 @@ type GMCPPartyModule_Payload_Vitals struct {
HealthPercent int `json:"health"` // 1 = 1%, 23 = 23% etc.
Location string `json:"location"` // Title of room they are in
}
+
+func (g *GMCPPartyModule) buildAndSendGMCPPayload(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(GMCPPartyUpdate)
+ if !typeOk {
+ return events.Continue
+ }
+
+ if evt.UserId < 1 {
+ return events.Continue
+ }
+
+ // Make sure they have GMCP enabled
+ user := users.GetByUserId(evt.UserId)
+ if user == nil {
+ return events.Continue
+ }
+
+ if !isGMCPEnabled(user.ConnectionId()) {
+ return events.Continue
+ }
+
+ // If requesting "Party", send all sub-nodes
+ if evt.Identifier == `Party` {
+ g.sendAllPartyNodes(evt.UserId)
+ return events.Continue
+ }
+
+ // Otherwise, send the specific node requested
+ party := parties.Get(evt.UserId)
+ // Note: party can be nil, GetPartyNode handles that case
+
+ payload, moduleName := g.GetPartyNode(party, evt.Identifier)
+ if payload != nil && moduleName != "" {
+ events.AddToQueue(GMCPOut{
+ UserId: evt.UserId,
+ Module: moduleName,
+ Payload: payload,
+ })
+ }
+
+ return events.Continue
+}
+
+// sendAllPartyNodes sends all Party nodes as individual GMCP messages
+func (g *GMCPPartyModule) sendAllPartyNodes(userId int) {
+ party := parties.Get(userId)
+
+ // Always send party structures, even if empty (when not in a party)
+ // Send Party.Info
+ if infoPayload, moduleName := g.GetPartyNode(party, `Party.Info`); infoPayload != nil {
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: moduleName,
+ Payload: infoPayload,
+ })
+ }
+
+ // Send Party.Vitals
+ if vitalsPayload, moduleName := g.GetPartyNode(party, `Party.Vitals`); vitalsPayload != nil {
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: moduleName,
+ Payload: vitalsPayload,
+ })
+ }
+}
diff --git a/modules/gmcp/gmcp.Room.go b/modules/gmcp/gmcp.Room.go
index 85b46ed2..60ece58e 100644
--- a/modules/gmcp/gmcp.Room.go
+++ b/modules/gmcp/gmcp.Room.go
@@ -13,30 +13,19 @@ import (
"github.com/GoMudEngine/GoMud/internal/plugins"
"github.com/GoMudEngine/GoMud/internal/rooms"
"github.com/GoMudEngine/GoMud/internal/users"
+ "github.com/GoMudEngine/GoMud/internal/util"
)
-// ////////////////////////////////////////////////////////////////////
-// NOTE: The init function in Go is a special function that is
-// automatically executed before the main function within a package.
-// It is used to initialize variables, set up configurations, or
-// perform any other setup tasks that need to be done before the
-// program starts running.
-// ////////////////////////////////////////////////////////////////////
func init() {
-
- //
- // We can use all functions only, but this demonstrates
- // how to use a struct
- //
g := GMCPRoomModule{
plug: plugins.New(`gmcp.Room`, `1.0`),
}
- // Temporary for testing purposes.
events.RegisterListener(events.RoomChange{}, g.roomChangeHandler)
events.RegisterListener(events.PlayerDespawn{}, g.despawnHandler)
events.RegisterListener(GMCPRoomUpdate{}, g.buildAndSendGMCPPayload)
-
+ events.RegisterListener(events.ItemOwnership{}, g.itemOwnershipHandler)
+ events.RegisterListener(events.ExitLockChanged{}, g.exitLockChangedHandler)
}
type GMCPRoomModule struct {
@@ -60,7 +49,6 @@ func (g *GMCPRoomModule) despawnHandler(e events.Event) events.ListenerReturn {
return events.Cancel
}
- // If this isn't a user changing rooms, just pass it along.
if evt.UserId == 0 {
return events.Continue
}
@@ -70,9 +58,6 @@ func (g *GMCPRoomModule) despawnHandler(e events.Event) events.ListenerReturn {
return events.Continue
}
- //
- // Send GMCP Updates for players leaving
- //
for _, uid := range room.GetPlayers() {
if uid == evt.UserId {
@@ -86,7 +71,8 @@ func (g *GMCPRoomModule) despawnHandler(e events.Event) events.ListenerReturn {
events.AddToQueue(GMCPOut{
UserId: uid,
- Payload: fmt.Sprintf(`Room.RemovePlayer "%s"`, evt.CharacterName),
+ Module: `Room.Remove.Player`,
+ Payload: map[string]string{"name": evt.CharacterName},
})
}
@@ -102,23 +88,25 @@ func (g *GMCPRoomModule) roomChangeHandler(e events.Event) events.ListenerReturn
return events.Cancel
}
- // Send updates to players in old/new rooms for
- // players or npcs (whichever changed)
- updateId := `Room.Info.Contents.Players`
- if evt.MobInstanceId > 0 {
- updateId = `Room.Info.Contents.Npcs`
- }
-
if evt.FromRoomId != 0 {
if oldRoom := rooms.LoadRoom(evt.FromRoomId); oldRoom != nil {
for _, uId := range oldRoom.GetPlayers() {
if uId == evt.UserId {
continue
}
- events.AddToQueue(GMCPRoomUpdate{
- UserId: uId,
- Identifier: updateId,
- })
+
+ if evt.MobInstanceId > 0 {
+ if mob := mobs.GetInstance(evt.MobInstanceId); mob != nil {
+ events.AddToQueue(GMCPOut{
+ UserId: uId,
+ Module: `Room.Remove.Npc`,
+ Payload: map[string]interface{}{
+ "id": mob.ShorthandId(),
+ "name": util.StripANSI(mob.Character.Name),
+ },
+ })
+ }
+ }
}
}
}
@@ -129,20 +117,67 @@ func (g *GMCPRoomModule) roomChangeHandler(e events.Event) events.ListenerReturn
if uId == evt.UserId {
continue
}
- events.AddToQueue(GMCPRoomUpdate{
- UserId: uId,
- Identifier: updateId,
- })
+
+ if evt.MobInstanceId > 0 {
+ if mob := mobs.GetInstance(evt.MobInstanceId); mob != nil {
+ threatLevel := "peaceful"
+ targeting := []string{}
+
+ viewer := users.GetByUserId(uId)
+ if viewer != nil {
+ if mob.Character.Aggro != nil {
+ if mob.Character.Aggro.UserId == uId {
+ threatLevel = "fighting"
+ } else {
+ threatLevel = "aggressive"
+ }
+ } else if mob.Hostile ||
+ (len(mob.Groups) > 0 && mobs.IsHostile(mob.Groups[0], uId)) ||
+ mob.HatesRace(viewer.Character.Race()) ||
+ mob.HatesAlignment(viewer.Character.Alignment) {
+ threatLevel = "hostile"
+ }
+ }
+
+ // Build list of players this mob is targeting
+ if len(mob.Character.PlayerDamage) > 0 {
+ for userId := range mob.Character.PlayerDamage {
+ if targetUser := users.GetByUserId(userId); targetUser != nil {
+ targeting = append(targeting, util.StripANSI(targetUser.Character.Name))
+ }
+ }
+ }
+
+ events.AddToQueue(GMCPOut{
+ UserId: uId,
+ Module: `Room.Add.Npc`,
+ Payload: map[string]interface{}{
+ "id": mob.ShorthandId(),
+ "name": util.StripANSI(mob.Character.Name),
+ "threat_level": threatLevel,
+ "targeting": targeting,
+ },
+ })
+ }
+ } else {
+ if user := users.GetByUserId(evt.UserId); user != nil {
+ events.AddToQueue(GMCPOut{
+ UserId: uId,
+ Module: `Room.Add.Player`,
+ Payload: map[string]string{
+ "name": util.StripANSI(user.Character.Name),
+ },
+ })
+ }
+ }
}
}
}
- // If it's a mob changing rooms, don't need to send it its own room info
if evt.UserId == 0 {
return events.Continue
}
- // Send update to the moved player about their new room.
events.AddToQueue(GMCPRoomUpdate{
UserId: evt.UserId,
Identifier: `Room.Info`,
@@ -163,7 +198,6 @@ func (g *GMCPRoomModule) buildAndSendGMCPPayload(e events.Event) events.Listener
return events.Continue
}
- // Make sure they have this gmcp module enabled.
user := users.GetByUserId(evt.UserId)
if user == nil {
return events.Continue
@@ -174,55 +208,53 @@ func (g *GMCPRoomModule) buildAndSendGMCPPayload(e events.Event) events.Listener
}
if len(evt.Identifier) >= 4 {
+ identifierParts := strings.Split(strings.ToLower(evt.Identifier), `.`)
+ for i := 0; i < len(identifierParts); i++ {
+ identifierParts[i] = strings.Title(identifierParts[i])
+ }
- for _, identifier := range strings.Split(evt.Identifier, `,`) {
-
- identifier = strings.TrimSpace(identifier)
-
- identifierParts := strings.Split(strings.ToLower(identifier), `.`)
- for i := 0; i < len(identifierParts); i++ {
- identifierParts[i] = strings.Title(identifierParts[i])
- }
-
- requestedId := strings.Join(identifierParts, `.`)
+ requestedId := strings.Join(identifierParts, `.`)
- payload, moduleName := g.GetRoomNode(user, requestedId)
+ payload, moduleName := g.GetRoomNode(user, requestedId)
+ if payload != nil && moduleName != "" {
events.AddToQueue(GMCPOut{
UserId: evt.UserId,
Module: moduleName,
Payload: payload,
})
-
}
-
}
return events.Continue
}
+func (g *GMCPRoomModule) sendAllRoomNodes(user *users.UserRecord) {
+ events.AddToQueue(GMCPRoomUpdate{UserId: user.UserId, Identifier: `Room.Info.Basic`})
+ events.AddToQueue(GMCPRoomUpdate{UserId: user.UserId, Identifier: `Room.Info.Exits`})
+ events.AddToQueue(GMCPRoomUpdate{UserId: user.UserId, Identifier: `Room.Info.Contents.Players`})
+ events.AddToQueue(GMCPRoomUpdate{UserId: user.UserId, Identifier: `Room.Info.Contents.Npcs`})
+ events.AddToQueue(GMCPRoomUpdate{UserId: user.UserId, Identifier: `Room.Info.Contents.Items`})
+ events.AddToQueue(GMCPRoomUpdate{UserId: user.UserId, Identifier: `Room.Info.Contents.Containers`})
+}
+
func (g *GMCPRoomModule) GetRoomNode(user *users.UserRecord, gmcpModule string) (data any, moduleName string) {
- all := gmcpModule == `Room.Info`
+ all := gmcpModule == `Room` || gmcpModule == `Room.Info`
+
+ if all {
+ g.sendAllRoomNodes(user)
+ return nil, ""
+ }
- // Get the new room data... abort if doesn't exist.
room := rooms.LoadRoom(user.Character.RoomId)
if room == nil {
- return GMCPRoomModule_Payload{}, `Room.Info`
+ return nil, ""
}
payload := GMCPRoomModule_Payload{}
- ////////////////////////////////////////////////
- // Room.Contents
- // Note: Process this first since we might be
- // sending a subset of data
- ////////////////////////////////////////////////
-
- ////////////////////////////////////////////////
- // Room.Contents.Containers
- ////////////////////////////////////////////////
- if all || g.wantsGMCPPayload(`Room.Info.Contents.Containers`, gmcpModule) {
+ if g.wantsGMCPPayload(`Room.Info.Contents.Containers`, gmcpModule) {
payload.Contents.Containers = []GMCPRoomModule_Payload_Contents_Container{}
for name, container := range room.Containers {
@@ -246,15 +278,12 @@ func (g *GMCPRoomModule) GetRoomNode(user *users.UserRecord, gmcpModule string)
}
}
- ////////////////////////////////////////////////
- // Room.Contents.Items
- ////////////////////////////////////////////////
- if all || g.wantsGMCPPayload(`Room.Info.Contents.Items`, gmcpModule) {
+ if g.wantsGMCPPayload(`Room.Info.Contents.Items`, gmcpModule) {
payload.Contents.Items = []GMCPRoomModule_Payload_Contents_Item{}
for _, itm := range room.Items {
payload.Contents.Items = append(payload.Contents.Items, GMCPRoomModule_Payload_Contents_Item{
Id: itm.ShorthandId(),
- Name: itm.Name(),
+ Name: util.StripANSI(itm.Name()),
QuestFlag: itm.GetSpec().QuestToken != ``,
})
}
@@ -264,14 +293,10 @@ func (g *GMCPRoomModule) GetRoomNode(user *users.UserRecord, gmcpModule string)
}
}
- ////////////////////////////////////////////////
- // Room.Contents.Players
- ////////////////////////////////////////////////
- if all || g.wantsGMCPPayload(`Room.Info.Contents.Players`, gmcpModule) {
+ if g.wantsGMCPPayload(`Room.Info.Contents.Players`, gmcpModule) {
payload.Contents.Players = []GMCPRoomModule_Payload_Contents_Character{}
for _, uId := range room.GetPlayers() {
- // Exclude viewing player
if uId == user.UserId {
continue
}
@@ -286,10 +311,11 @@ func (g *GMCPRoomModule) GetRoomNode(user *users.UserRecord, gmcpModule string)
}
payload.Contents.Players = append(payload.Contents.Players, GMCPRoomModule_Payload_Contents_Character{
- Id: u.ShorthandId(),
- Name: u.Character.Name,
- Adjectives: u.Character.GetAdjectives(),
- Aggro: u.Character.Aggro != nil,
+ Id: u.ShorthandId(),
+ Name: util.StripANSI(u.Character.Name),
+ Adjectives: u.Character.GetAdjectives(),
+ ThreatLevel: "peaceful",
+ Targeting: []string{}, // Players don't target other players in this context
})
}
@@ -298,10 +324,7 @@ func (g *GMCPRoomModule) GetRoomNode(user *users.UserRecord, gmcpModule string)
}
}
- ////////////////////////////////////////////////
- // Room.Contents.Npcs
- ////////////////////////////////////////////////
- if all || g.wantsGMCPPayload(`Room.Info.Contents.Npcs`, gmcpModule) {
+ if g.wantsGMCPPayload(`Room.Info.Contents.Npcs`, gmcpModule) {
payload.Contents.Npcs = []GMCPRoomModule_Payload_Contents_Character{}
for _, mIId := range room.GetMobs() {
mob := mobs.GetInstance(mIId)
@@ -313,11 +336,38 @@ func (g *GMCPRoomModule) GetRoomNode(user *users.UserRecord, gmcpModule string)
continue
}
+ threatLevel := "peaceful"
+ targeting := []string{}
+
+ // Check mob's current aggro target
+ if mob.Character.Aggro != nil {
+ if mob.Character.Aggro.UserId == user.UserId {
+ threatLevel = "fighting"
+ } else {
+ threatLevel = "aggressive"
+ }
+ } else if mob.Hostile ||
+ (len(mob.Groups) > 0 && mobs.IsHostile(mob.Groups[0], user.UserId)) ||
+ mob.HatesRace(user.Character.Race()) ||
+ mob.HatesAlignment(user.Character.Alignment) {
+ threatLevel = "hostile"
+ }
+
+ // Build list of players this mob is targeting (based on damage tracking)
+ if len(mob.Character.PlayerDamage) > 0 {
+ for userId := range mob.Character.PlayerDamage {
+ if targetUser := users.GetByUserId(userId); targetUser != nil {
+ targeting = append(targeting, util.StripANSI(targetUser.Character.Name))
+ }
+ }
+ }
+
c := GMCPRoomModule_Payload_Contents_Character{
- Id: mob.ShorthandId(),
- Name: mob.Character.Name,
- Adjectives: mob.Character.GetAdjectives(),
- Aggro: mob.Character.Aggro != nil,
+ Id: mob.ShorthandId(),
+ Name: util.StripANSI(mob.Character.Name),
+ Adjectives: mob.Character.GetAdjectives(),
+ ThreatLevel: threatLevel,
+ Targeting: targeting,
}
if len(mob.QuestFlags) > 0 {
@@ -342,22 +392,23 @@ func (g *GMCPRoomModule) GetRoomNode(user *users.UserRecord, gmcpModule string)
return payload.Contents, `Room.Info.Contents`
}
- ////////////////////////////////////////////////
- // Room.Info
- // Note: This populates the root Room.Info data
- ////////////////////////////////////////////////
- if all || g.wantsGMCPPayload(`Room.Info`, gmcpModule) {
+ if g.wantsGMCPPayload(`Room.Info.Basic`, gmcpModule) || g.wantsGMCPPayload(`Room.Info.Exits`, gmcpModule) {
- // Basic details
payload.Id = room.RoomId
- payload.Name = room.Title
+ payload.Name = util.StripANSI(room.Title)
payload.Area = room.Zone
payload.Environment = room.GetBiome().Name
payload.Details = []string{}
- // Coordinates
payload.Coordinates = room.Zone
m := mapper.GetMapper(room.RoomId)
+
+ // Generate map_id based on the lowest room ID in the connected area
+ // This provides a stable identifier regardless of which room triggered map creation
+ if m != nil {
+ payload.MapId = fmt.Sprintf("map-%d", m.GetLowestRoomId())
+ }
+
x, y, z, err := m.GetCoordinates(room.RoomId)
if err != nil {
payload.Coordinates += `, 999999999999999999, 999999999999999999, 999999999999999999`
@@ -365,9 +416,7 @@ func (g *GMCPRoomModule) GetRoomNode(user *users.UserRecord, gmcpModule string)
payload.Coordinates += `, ` + strconv.Itoa(x) + `, ` + strconv.Itoa(y) + `, ` + strconv.Itoa(z)
}
- // set exits
- payload.Exits = map[string]int{}
- payload.ExitsV2 = map[string]GMCPRoomModule_Payload_Contents_ExitInfo{}
+ payload.Exits = map[string]GMCPRoomModule_Payload_Contents_ExitInfo{}
for exitName, exitInfo := range room.Exits {
@@ -379,14 +428,9 @@ func (g *GMCPRoomModule) GetRoomNode(user *users.UserRecord, gmcpModule string)
}
}
- // Skip non compass directions?
- if !mapper.IsCompassDirection(exitName) {
- //continue
- }
-
- payload.Exits[exitName] = exitInfo.RoomId
+ // Include all exits, not just compass directions
+ // Custom exits are important for gameplay
- // Form the "exitV2"
deltaX, deltaY, deltaZ := 0, 0, 0
if len(exitInfo.MapDirection) > 0 {
deltaX, deltaY, deltaZ = mapper.GetDelta(exitInfo.MapDirection)
@@ -395,38 +439,56 @@ func (g *GMCPRoomModule) GetRoomNode(user *users.UserRecord, gmcpModule string)
}
exitV2 := GMCPRoomModule_Payload_Contents_ExitInfo{
- RoomId: exitInfo.RoomId,
- DeltaX: deltaX,
- DeltaY: deltaY,
- DeltaZ: deltaZ,
- Details: []string{},
+ RoomId: exitInfo.RoomId,
+ DeltaX: deltaX,
+ DeltaY: deltaY,
+ DeltaZ: deltaZ,
}
- if exitInfo.Secret {
- exitV2.Details = append(exitV2.Details, `secret`)
+ // Check if this exit leads to a different map area
+ if targetMapper := mapper.GetMapperIfExists(exitInfo.RoomId); targetMapper != nil {
+ targetMapId := fmt.Sprintf("map-%d", targetMapper.GetLowestRoomId())
+ if targetMapId != payload.MapId {
+ if exitV2.Details == nil {
+ exitV2.Details = make(map[string]interface{})
+ }
+ exitV2.Details["leads_to_map"] = targetMapId
+
+ // Also include the area name of the destination
+ if targetRoom := rooms.LoadRoom(exitInfo.RoomId); targetRoom != nil {
+ exitV2.Details["leads_to_area"] = targetRoom.Zone
+ }
+ }
}
if exitInfo.HasLock() {
+ if exitV2.Details == nil {
+ exitV2.Details = make(map[string]interface{})
+ }
+ exitV2.Details["type"] = "door"
+ exitV2.Details["name"] = exitName
- exitV2.Details = append(exitV2.Details, `locked`)
+ if exitInfo.Lock.IsLocked() {
+ exitV2.Details["state"] = "locked"
+ } else {
+ exitV2.Details["state"] = "open"
+ }
lockId := fmt.Sprintf(`%d-%s`, room.RoomId, exitName)
haskey, hascombo := user.Character.HasKey(lockId, int(exitInfo.Lock.Difficulty))
if haskey {
- exitV2.Details = append(exitV2.Details, `player_has_key`)
+ exitV2.Details["hasKey"] = true
}
if hascombo {
- exitV2.Details = append(exitV2.Details, `player_has_pick_combo`)
+ exitV2.Details["hasPicked"] = true
}
}
- payload.ExitsV2[exitName] = exitV2
+ payload.Exits[exitName] = exitV2
}
- // end exits
- // Set room details
if len(room.SkillTraining) > 0 {
payload.Details = append(payload.Details, `trainer`)
}
@@ -440,20 +502,59 @@ func (g *GMCPRoomModule) GetRoomNode(user *users.UserRecord, gmcpModule string)
payload.Details = append(payload.Details, `character`)
}
- // Indicate if this is an ephemeral room
if rooms.IsEphemeralRoomId(room.RoomId) {
payload.Details = append(payload.Details, `ephemeral`)
}
- // end room details
+ if gmcpModule == `Room.Info.Basic` {
+ basicPayload := map[string]interface{}{
+ "id": payload.Id,
+ "name": payload.Name,
+ "area": payload.Area,
+ "map_id": payload.MapId,
+ "environment": payload.Environment,
+ "coordinates": payload.Coordinates,
+ "details": payload.Details,
+ }
+ return basicPayload, `Room.Info.Basic`
+ }
+
+ if gmcpModule == `Room.Info.Exits` {
+ return payload.Exits, `Room.Info.Exits`
+ }
+ }
+
+ if gmcpModule == `Room.Wrongdir` {
+ return map[string]string{"dir": ""}, `Room.Wrongdir`
}
- // If we reached this point and Char wasn't requested, we have a problem.
- if !all {
- mudlog.Error(`gmcp.Room`, `error`, `Bad module requested`, `module`, gmcpModule)
+ if gmcpModule == `Room.Add.Player` {
+ return map[string]string{"name": ""}, `Room.Add.Player`
+ }
+ if gmcpModule == `Room.Add.Npc` {
+ return map[string]interface{}{
+ "id": "",
+ "name": "",
+ "threat_level": "",
+ "targeting": []string{},
+ }, `Room.Add.Npc`
+ }
+ if gmcpModule == `Room.Add.Item` {
+ return map[string]interface{}{"id": "", "name": "", "quest_flag": false}, `Room.Add.Item`
+ }
+
+ if gmcpModule == `Room.Remove.Player` {
+ return map[string]string{"name": ""}, `Room.Remove.Player`
+ }
+ if gmcpModule == `Room.Remove.Npc` {
+ return map[string]interface{}{"id": "", "name": ""}, `Room.Remove.Npc`
+ }
+ if gmcpModule == `Room.Remove.Item` {
+ return map[string]interface{}{"id": "", "name": ""}, `Room.Remove.Item`
}
- return payload, `Room.Info`
+ mudlog.Error(`gmcp.Room`, `error`, `Bad module requested`, `module`, gmcpModule)
+ return nil, ""
}
// wantsGMCPPayload(`Room.Info.Contents`, `Room.Info`)
@@ -475,41 +576,42 @@ func (g *GMCPRoomModule) wantsGMCPPayload(packageToConsider string, packageReque
}
type GMCPRoomModule_Payload struct {
- Id int `json:"num"`
+ Id int `json:"id"`
Name string `json:"name"`
Area string `json:"area"`
+ MapId string `json:"map_id"`
Environment string `json:"environment"`
- Coordinates string `json:"coords"`
- Exits map[string]int `json:"exits"`
- ExitsV2 map[string]GMCPRoomModule_Payload_Contents_ExitInfo `json:"exitsv2"`
+ Coordinates string `json:"coordinates"`
+ Exits map[string]GMCPRoomModule_Payload_Contents_ExitInfo `json:"exits"`
Details []string `json:"details"`
- Contents GMCPRoomModule_Payload_Contents `json:"Contents"`
+ Contents GMCPRoomModule_Payload_Contents `json:"contents"`
}
type GMCPRoomModule_Payload_Contents_ExitInfo struct {
- RoomId int `json:"num"`
- DeltaX int `json:"dx"`
- DeltaY int `json:"dy"`
- DeltaZ int `json:"dz"`
- Details []string `json:"details"`
+ RoomId int `json:"room_id"`
+ DeltaX int `json:"delta_x"`
+ DeltaY int `json:"delta_y"`
+ DeltaZ int `json:"delta_z"`
+ Details map[string]interface{} `json:"details,omitempty"` // Only populated for special exits
}
// /////////////////
// Room.Contents
// /////////////////
type GMCPRoomModule_Payload_Contents struct {
- Players []GMCPRoomModule_Payload_Contents_Character `json:"Players"`
- Npcs []GMCPRoomModule_Payload_Contents_Character `json:"Npcs"`
- Items []GMCPRoomModule_Payload_Contents_Item `json:"Items"`
- Containers []GMCPRoomModule_Payload_Contents_Container `json:"Containers"`
+ Players []GMCPRoomModule_Payload_Contents_Character `json:"players"`
+ Npcs []GMCPRoomModule_Payload_Contents_Character `json:"npcs"`
+ Items []GMCPRoomModule_Payload_Contents_Item `json:"items"`
+ Containers []GMCPRoomModule_Payload_Contents_Container `json:"containers"`
}
type GMCPRoomModule_Payload_Contents_Character struct {
- Id string `json:"id"`
- Name string `json:"name"`
- Adjectives []string `json:"adjectives"`
- Aggro bool `json:"aggro"`
- QuestFlag bool `json:"quest_flag"`
+ Id string `json:"id"`
+ Name string `json:"name"`
+ Adjectives []string `json:"adjectives"`
+ QuestFlag bool `json:"quest_flag"`
+ ThreatLevel string `json:"threat_level"` // "peaceful", "hostile", "aggressive", "fighting"
+ Targeting []string `json:"targeting"` // array of player names this mob is targeting
}
type GMCPRoomModule_Payload_Contents_Item struct {
@@ -521,7 +623,79 @@ type GMCPRoomModule_Payload_Contents_Item struct {
type GMCPRoomModule_Payload_Contents_Container struct {
Name string `json:"name"`
Locked bool `json:"locked"`
- HasKey bool `json:"haskey"`
- HasPickCombo bool `json:"haspickcombo"`
+ HasKey bool `json:"has_key"`
+ HasPickCombo bool `json:"has_pick_combo"`
Usable bool `json:"usable"`
}
+
+func (g *GMCPRoomModule) itemOwnershipHandler(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(events.ItemOwnership)
+ if !typeOk {
+ return events.Continue
+ }
+
+ // We need to determine if this is a room-related item change
+ // When a user drops an item: UserId > 0 and Gained = false
+ // When a user picks up an item: UserId > 0 and Gained = true
+
+ if evt.UserId > 0 {
+ user := users.GetByUserId(evt.UserId)
+ if user == nil {
+ return events.Continue
+ }
+
+ room := rooms.LoadRoom(user.Character.RoomId)
+ if room == nil {
+ return events.Continue
+ }
+
+ // Send updates to all players in the room
+ for _, uid := range room.GetPlayers() {
+ if !evt.Gained {
+ // User dropped item (item added to room)
+ events.AddToQueue(GMCPOut{
+ UserId: uid,
+ Module: `Room.Add.Item`,
+ Payload: map[string]interface{}{
+ "id": evt.Item.ShorthandId(),
+ "name": util.StripANSI(evt.Item.Name()),
+ "quest_flag": evt.Item.GetSpec().QuestToken != ``,
+ },
+ })
+ } else {
+ // User picked up item (item removed from room)
+ events.AddToQueue(GMCPOut{
+ UserId: uid,
+ Module: `Room.Remove.Item`,
+ Payload: map[string]interface{}{
+ "id": evt.Item.ShorthandId(),
+ "name": util.StripANSI(evt.Item.Name()),
+ },
+ })
+ }
+ }
+ }
+
+ return events.Continue
+}
+
+func (g *GMCPRoomModule) exitLockChangedHandler(e events.Event) events.ListenerReturn {
+ evt, typeOk := e.(events.ExitLockChanged)
+ if !typeOk {
+ mudlog.Error("Event", "Expected Type", "ExitLockChanged", "Actual Type", e.Type())
+ return events.Cancel
+ }
+
+ // Load the room where the exit changed
+ room := rooms.LoadRoom(evt.RoomId)
+ if room == nil {
+ return events.Continue
+ }
+
+ // Send exit updates to all players in the room
+ for _, userId := range room.GetPlayers() {
+ events.AddToQueue(GMCPRoomUpdate{UserId: userId, Identifier: `Room.Info.Exits`})
+ }
+
+ return events.Continue
+}
diff --git a/modules/gmcp/gmcp.go b/modules/gmcp/gmcp.go
index b4a96a41..9cee0ddf 100644
--- a/modules/gmcp/gmcp.go
+++ b/modules/gmcp/gmcp.go
@@ -1,17 +1,26 @@
+// Package gmcp implements the Generic MUD Communication Protocol for real-time
+// client-server data exchange over Telnet IAC subnegotiation or WebSocket frames.
+//
+// GMCP provides structured JSON data updates to clients for UI state management,
+// including character stats, room information, combat state, and party data.
+// The protocol is transport-agnostic, supporting both traditional Telnet and modern WebSocket connections.
package gmcp
import (
- "bytes"
+ "embed"
"encoding/json"
- "fmt"
- "os"
"strconv"
"strings"
+ "sync"
+
+ "github.com/GoMudEngine/GoMud/internal/configs"
"github.com/GoMudEngine/GoMud/internal/connections"
"github.com/GoMudEngine/GoMud/internal/events"
+ "github.com/GoMudEngine/GoMud/internal/mobs"
"github.com/GoMudEngine/GoMud/internal/mudlog"
"github.com/GoMudEngine/GoMud/internal/plugins"
+ "github.com/GoMudEngine/GoMud/internal/rooms"
"github.com/GoMudEngine/GoMud/internal/term"
"github.com/GoMudEngine/GoMud/internal/users"
lru "github.com/hashicorp/golang-lru/v2"
@@ -22,54 +31,62 @@ const (
)
var (
- ///////////////////////////
- // GMCP COMMANDS
- ///////////////////////////
- GmcpEnable = term.TerminalCommand{Chars: []byte{term.TELNET_IAC, term.TELNET_WILL, TELNET_GMCP}, EndChars: []byte{}} // Indicates the server wants to enable GMCP.
- GmcpDisable = term.TerminalCommand{Chars: []byte{term.TELNET_IAC, term.TELNET_WONT, TELNET_GMCP}, EndChars: []byte{}} // Indicates the server wants to disable GMCP.
+ //go:embed files/*
+ files embed.FS
+
+ // Telnet IAC negotiation sequences for GMCP protocol
+ GmcpEnable = term.TerminalCommand{Chars: []byte{term.TELNET_IAC, term.TELNET_WILL, TELNET_GMCP}, EndChars: []byte{}}
+ GmcpDisable = term.TerminalCommand{Chars: []byte{term.TELNET_IAC, term.TELNET_WONT, TELNET_GMCP}, EndChars: []byte{}}
- GmcpAccept = term.TerminalCommand{Chars: []byte{term.TELNET_IAC, term.TELNET_DO, TELNET_GMCP}, EndChars: []byte{}} // Indicates the client accepts GMCP sub-negotiations.
- GmcpRefuse = term.TerminalCommand{Chars: []byte{term.TELNET_IAC, term.TELNET_DONT, TELNET_GMCP}, EndChars: []byte{}} // Indicates the client refuses GMCP sub-negotiations.
+ GmcpAccept = term.TerminalCommand{Chars: []byte{term.TELNET_IAC, term.TELNET_DO, TELNET_GMCP}, EndChars: []byte{}}
+ GmcpRefuse = term.TerminalCommand{Chars: []byte{term.TELNET_IAC, term.TELNET_DONT, TELNET_GMCP}, EndChars: []byte{}}
- GmcpPayload = term.TerminalCommand{Chars: []byte{term.TELNET_IAC, term.TELNET_SB, TELNET_GMCP}, EndChars: []byte{term.TELNET_IAC, term.TELNET_SE}} // Wrapper for sending GMCP payloads
- GmcpWebPayload = term.TerminalCommand{Chars: []byte("!!GMCP("), EndChars: []byte{')'}} // Wrapper for sending GMCP payloads
+ // GMCP payload wrappers for different transport types
+ GmcpPayload = term.TerminalCommand{Chars: []byte{term.TELNET_IAC, term.TELNET_SB, TELNET_GMCP}, EndChars: []byte{term.TELNET_IAC, term.TELNET_SE}}
+ GmcpWebPayload = term.TerminalCommand{Chars: []byte("!!GMCP("), EndChars: []byte{')'}}
gmcpModule GMCPModule = GMCPModule{}
+
+ // Central combat state tracking - shared by all combat modules
+ combatUsersMutex sync.RWMutex
+ combatUsers = make(map[int]struct{})
)
-// ////////////////////////////////////////////////////////////////////
-// NOTE: The init function in Go is a special function that is
-// automatically executed before the main function within a package.
-// It is used to initialize variables, set up configurations, or
-// perform any other setup tasks that need to be done before the
-// program starts running.
-// ////////////////////////////////////////////////////////////////////
func init() {
-
- //
- // We can use all functions only, but this demonstrates
- // how to use a struct
- //
gmcpModule = GMCPModule{
plug: plugins.New(`gmcp`, `1.0`),
}
- // connectionId to map[string]int
+ // LRU cache limits memory usage for connection settings to 128 entries
gmcpModule.cache, _ = lru.New[uint64, GMCPSettings](128)
+ // Embedded filesystem provides config overlays without disk access
+ if err := gmcpModule.plug.AttachFileSystem(files); err != nil {
+ panic(err)
+ }
+
+ gmcpModule.plug.Callbacks.SetOnLoad(gmcpModule.load)
+ gmcpModule.plug.Callbacks.SetOnSave(gmcpModule.save)
+
gmcpModule.plug.ExportFunction(`SendGMCPEvent`, gmcpModule.sendGMCPEvent)
gmcpModule.plug.ExportFunction(`IsMudlet`, gmcpModule.IsMudletExportedFunction)
+ gmcpModule.plug.ExportFunction(`TriggerRoomUpdate`, gmcpModule.triggerRoomUpdate)
gmcpModule.plug.Callbacks.SetIACHandler(gmcpModule.HandleIAC)
gmcpModule.plug.Callbacks.SetOnNetConnect(gmcpModule.onNetConnect)
+ events.RegisterListener(events.CombatStarted{}, handleCombatStartedTracking)
+ events.RegisterListener(events.CombatEnded{}, handleCombatEndedTracking)
+ events.RegisterListener(events.PlayerDespawn{}, handlePlayerDespawnTracking)
+
events.RegisterListener(GMCPOut{}, gmcpModule.dispatchGMCP)
events.RegisterListener(events.PlayerSpawn{}, gmcpModule.handlePlayerJoin)
+ InitCombatCooldownTimer()
+
+ initMudlet()
}
func isGMCPEnabled(connectionId uint64) bool {
-
- //return true
if gmcpData, ok := gmcpModule.cache.Get(connectionId); ok {
return gmcpData.GMCPAccepted
}
@@ -77,9 +94,26 @@ func isGMCPEnabled(connectionId uint64) bool {
return false
}
-// ///////////////////
-// EVENTS
-// ///////////////////
+// validateUserForGMCP provides centralized validation for all GMCP operations.
+// Prevents operations on disconnected users and non-GMCP clients.
+func validateUserForGMCP(userId int, module string) (*users.UserRecord, bool) {
+ if userId < 1 {
+ return nil, false
+ }
+
+ user := users.GetByUserId(userId)
+ if user == nil {
+ mudlog.Warn(module, "action", "validateUserForGMCP", "issue", "user not found", "userId", userId)
+ return nil, false
+ }
+
+ gmcpEnabled := isGMCPEnabled(user.ConnectionId())
+ if !gmcpEnabled {
+ return nil, false
+ }
+
+ return user, true
+}
type GMCPOut struct {
UserId int
@@ -89,11 +123,7 @@ type GMCPOut struct {
func (g GMCPOut) Type() string { return `GMCPOut` }
-// ///////////////////
-// END EVENTS
-// ///////////////////
type GMCPModule struct {
- // Keep a reference to the plugin when we create it so that we can call ReadBytes() and WriteBytes() on it.
plug *plugins.Plugin
cache *lru.Cache[uint64, GMCPSettings]
}
@@ -112,22 +142,140 @@ type GMCPLogin struct {
Password string
}
-// / SETTINGS
type GMCPSettings struct {
Client struct {
Name string
Version string
- IsMudlet bool // Knowing whether is a mudlet client can be useful, since Mudlet hates certain ANSI/Escape codes.
+ IsMudlet bool // Mudlet requires special handling for ANSI codes and mapper integration
}
- GMCPAccepted bool // Do they accept GMCP data?
- EnabledModules map[string]int // What modules/versions are accepted? Might not be used properly by clients.
+ GMCPAccepted bool
}
func (gs *GMCPSettings) IsMudlet() bool {
return gs.Client.IsMudlet
}
-/// END SETTINGS
+func handleCombatStartedTracking(e events.Event) events.ListenerReturn {
+ mudlog.Info("GMCP Combat Tracking", "event", "CombatStarted received")
+ evt, ok := e.(events.CombatStarted)
+ if !ok {
+ mudlog.Error("GMCP Combat Tracking", "error", "CombatStarted type assertion failed")
+ return events.Continue
+ }
+
+ mudlog.Info("GMCP Combat Tracking", "event", "CombatStarted",
+ "attackerType", evt.AttackerType, "attackerId", evt.AttackerId,
+ "defenderType", evt.DefenderType, "defenderId", evt.DefenderId,
+ "initiatedBy", evt.InitiatedBy)
+
+ if evt.AttackerType == "player" {
+ combatUsersMutex.Lock()
+ combatUsers[evt.AttackerId] = struct{}{}
+ combatUsersMutex.Unlock()
+ TrackCombatPlayer(evt.AttackerId)
+ mudlog.Info("GMCP Combat Tracking", "action", "Added player to combat", "userId", evt.AttackerId)
+ }
+
+ if evt.DefenderType == "player" {
+ combatUsersMutex.Lock()
+ combatUsers[evt.DefenderId] = struct{}{}
+ combatUsersMutex.Unlock()
+ TrackCombatPlayer(evt.DefenderId)
+ mudlog.Info("GMCP Combat Tracking", "action", "Added player to combat", "userId", evt.DefenderId)
+ }
+
+ return events.Continue
+}
+
+func handleCombatEndedTracking(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.CombatEnded)
+ if !ok || evt.EntityType != "player" {
+ return events.Continue
+ }
+
+ combatUsersMutex.Lock()
+ delete(combatUsers, evt.EntityId)
+ combatUsersMutex.Unlock()
+
+ UntrackCombatPlayer(evt.EntityId)
+
+ return events.Continue
+}
+
+func handlePlayerDespawnTracking(e events.Event) events.ListenerReturn {
+ evt, ok := e.(events.PlayerDespawn)
+ if !ok {
+ return events.Continue
+ }
+
+ combatUsersMutex.Lock()
+ delete(combatUsers, evt.UserId)
+ combatUsersMutex.Unlock()
+
+ CleanupUser(evt.UserId)
+
+ return events.Continue
+}
+
+func GetUsersInCombat() []int {
+ combatUsersMutex.RLock()
+ defer combatUsersMutex.RUnlock()
+
+ usersInCombat := make([]int, 0, len(combatUsers))
+ for userId := range combatUsers {
+ usersInCombat = append(usersInCombat, userId)
+ }
+ return usersInCombat
+}
+
+// IsUserInCombat is the authoritative source for combat state.
+// Returns true if user is attacking (has aggro) or being attacked (mob has aggro on them).
+func IsUserInCombat(userId int) bool {
+ user := users.GetByUserId(userId)
+ if user == nil {
+ return false
+ }
+
+ // Both UserId and MobInstanceId checked since aggro can target either players or mobs
+ if user.Character.Aggro != nil && (user.Character.Aggro.UserId > 0 || user.Character.Aggro.MobInstanceId > 0) {
+ return true
+ }
+
+ room := rooms.LoadRoom(user.Character.RoomId)
+ if room == nil {
+ return false
+ }
+
+ for _, mobId := range room.GetMobs() {
+ if mob := mobs.GetInstance(mobId); mob != nil {
+ if mob.Character.Aggro != nil && mob.Character.Aggro.UserId == userId {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+func GetUsersInOrTargetedByCombat() []int {
+ result := []int{}
+
+ for _, user := range users.GetAllActiveUsers() {
+ if IsUserInCombat(user.UserId) {
+ result = append(result, user.UserId)
+ }
+ }
+
+ return result
+}
+
+// CleanupUser orchestrates cleanup across all GMCP modules to prevent memory leaks
+func CleanupUser(userId int) {
+ cleanupCombatStatus(userId)
+ cleanupCombatTargetNew(userId)
+ cleanupCombatEnemiesNew(userId)
+ UntrackCombatPlayer(userId)
+}
func (g *GMCPModule) IsMudletExportedFunction(connectionId uint64) bool {
gmcpData, ok := g.cache.Get(connectionId)
@@ -157,6 +305,12 @@ func (g *GMCPModule) isGMCPCommand(b []byte) bool {
return len(b) > 2 && b[0] == term.TELNET_IAC && b[2] == TELNET_GMCP
}
+func (g *GMCPModule) load() {
+}
+
+func (g *GMCPModule) save() {
+}
+
func (g *GMCPModule) sendGMCPEvent(userId int, moduleName string, payload any) {
evt := GMCPOut{
@@ -168,6 +322,13 @@ func (g *GMCPModule) sendGMCPEvent(userId int, moduleName string, payload any) {
events.AddToQueue(evt)
}
+func (g *GMCPModule) triggerRoomUpdate(userId int) {
+ events.AddToQueue(GMCPRoomUpdate{
+ UserId: userId,
+ Identifier: `Room.Info`,
+ })
+}
+
func (g *GMCPModule) handlePlayerJoin(e events.Event) events.ListenerReturn {
evt, typeOk := e.(events.PlayerSpawn)
@@ -178,14 +339,17 @@ func (g *GMCPModule) handlePlayerJoin(e events.Event) events.ListenerReturn {
if _, ok := g.cache.Get(evt.ConnectionId); !ok {
g.cache.Add(evt.ConnectionId, GMCPSettings{})
- // Send request to enable GMCP
g.sendGMCPEnableRequest(evt.ConnectionId)
}
+ if evt.UserId > 0 {
+ SendFullGMCPUpdate(evt.UserId)
+ mudlog.Info("GMCP", "type", "PlayerSpawn", "action", "Full GMCP sent on login", "userId", evt.UserId)
+ }
+
return events.Continue
}
-// Sends a telnet IAC request to enable GMCP
func (g *GMCPModule) sendGMCPEnableRequest(connectionId uint64) {
connections.SendTo(
GmcpEnable.BytesWithPayload(nil),
@@ -193,18 +357,14 @@ func (g *GMCPModule) sendGMCPEnableRequest(connectionId uint64) {
)
}
-// Returns a map of module name to version number
func (s GMCPSupportsSet) GetSupportedModules() map[string]int {
-
ret := map[string]int{}
for _, entry := range s {
-
parts := strings.Split(entry, ` `)
if len(parts) == 2 {
ret[parts[0]], _ = strconv.Atoi(parts[1])
}
-
}
return ret
@@ -216,7 +376,7 @@ func (g *GMCPModule) HandleIAC(connectionId uint64, iacCmd []byte) bool {
return false
}
- if ok, payload := term.Matches(iacCmd, GmcpAccept); ok {
+ if ok, _ := term.Matches(iacCmd, GmcpAccept); ok {
gmcpData, ok := g.cache.Get(connectionId)
if !ok {
@@ -225,11 +385,10 @@ func (g *GMCPModule) HandleIAC(connectionId uint64, iacCmd []byte) bool {
gmcpData.GMCPAccepted = true
g.cache.Add(connectionId, gmcpData)
- mudlog.Debug("Received", "type", "IAC (Client-GMCP Accept)", "data", term.BytesString(payload))
return true
}
- if ok, payload := term.Matches(iacCmd, GmcpRefuse); ok {
+ if ok, _ := term.Matches(iacCmd, GmcpRefuse); ok {
gmcpData, ok := g.cache.Get(connectionId)
if !ok {
@@ -238,19 +397,15 @@ func (g *GMCPModule) HandleIAC(connectionId uint64, iacCmd []byte) bool {
gmcpData.GMCPAccepted = false
g.cache.Add(connectionId, gmcpData)
- mudlog.Debug("Received", "type", "IAC (Client-GMCP Refuse)", "data", term.BytesString(payload))
return true
}
if len(iacCmd) >= 5 && iacCmd[len(iacCmd)-2] == term.TELNET_IAC && iacCmd[len(iacCmd)-1] == term.TELNET_SE {
- // Unhanlded IAC command, log it
-
requestBody := iacCmd[3 : len(iacCmd)-2]
- //mudlog.Debug("Received", "type", "GMCP", "size", len(iacCmd), "data", string(requestBody))
spaceAt := 0
for i := 0; i < len(requestBody); i++ {
- if requestBody[i] == 32 {
+ if requestBody[i] == 32 { // ASCII space separates command from JSON payload
spaceAt = i
break
}
@@ -266,8 +421,6 @@ func (g *GMCPModule) HandleIAC(connectionId uint64, iacCmd []byte) bool {
command = string(requestBody)
}
- mudlog.Debug("Received", "type", "GMCP (Handling)", "command", command, "payload", string(payload))
-
switch command {
case `Core.Hello`:
@@ -286,9 +439,7 @@ func (g *GMCPModule) HandleIAC(connectionId uint64, iacCmd []byte) bool {
if strings.EqualFold(decoded.Client, `mudlet`) {
gmcpData.Client.IsMudlet = true
- // Trigger the Mudlet detected event
userId := 0
- // Try to find the user ID associated with this connection
for _, user := range users.GetAllActiveUsers() {
if user.ConnectionId() == connectionId {
userId = user.UserId
@@ -307,65 +458,73 @@ func (g *GMCPModule) HandleIAC(connectionId uint64, iacCmd []byte) bool {
g.cache.Add(connectionId, gmcpData)
}
case `Core.Supports.Set`:
- decoded := GMCPSupportsSet{}
+ // Intentionally ignored - we send all modules regardless of client preferences
+ case `Core.Supports.Remove`:
+ // Intentionally ignored - we send all modules regardless of client preferences
+ case `Char.Login`:
+ decoded := GMCPLogin{}
if err := json.Unmarshal(payload, &decoded); err == nil {
+ }
- gmcpData, ok := g.cache.Get(connectionId)
- if !ok {
- gmcpData = GMCPSettings{}
- gmcpData.GMCPAccepted = true
+ case `GMCP`:
+ payloadStr := string(payload)
+ userId := 0
+ for _, user := range users.GetAllActiveUsers() {
+ if user.ConnectionId() == connectionId {
+ userId = user.UserId
+ break
}
+ }
- gmcpData.EnabledModules = map[string]int{}
-
- for name, value := range decoded.GetSupportedModules() {
-
- // Break it down into:
- // Char.Inventory.Backpack
- // Char.Inventory
- // Char
- for {
- gmcpData.EnabledModules[name] = value
- idx := strings.LastIndex(name, ".")
- if idx == -1 {
- break
+ if userId > 0 {
+ switch {
+ case payloadStr == `SendFullPayload`:
+ SendFullGMCPUpdate(userId)
+ mudlog.Info("GMCP", "type", "GMCP", "action", "Full refresh requested", "userId", userId)
+
+ case strings.HasPrefix(payloadStr, `Send`):
+ // Convert CamelCase commands to dot notation for module routing
+ // Example: "SendCharInventoryBackpack" -> "Char.Inventory.Backpack"
+ nodePath := payloadStr[4:]
+ var dotPath strings.Builder
+ for i, char := range nodePath {
+ if i > 0 && char >= 'A' && char <= 'Z' {
+ dotPath.WriteRune('.')
}
- name = name[:idx]
+ dotPath.WriteRune(char)
}
- }
-
- g.cache.Add(connectionId, gmcpData)
-
- }
- case `Core.Supports.Remove`:
- decoded := GMCPSupportsRemove{}
- if err := json.Unmarshal(payload, &decoded); err == nil {
-
- gmcpData, ok := g.cache.Get(connectionId)
- if !ok {
- gmcpData = GMCPSettings{}
- gmcpData.GMCPAccepted = true
- }
-
- if len(gmcpData.EnabledModules) > 0 {
- for _, name := range decoded {
- delete(gmcpData.EnabledModules, name)
+ identifier := dotPath.String()
+ mudlog.Info("GMCP", "type", "GMCP", "action", "Node refresh requested", "userId", userId, "node", identifier)
+
+ if strings.HasPrefix(identifier, "Char") {
+ events.AddToQueue(GMCPCharUpdate{UserId: userId, Identifier: identifier})
+ } else if strings.HasPrefix(identifier, "Room") {
+ events.AddToQueue(GMCPRoomUpdate{UserId: userId, Identifier: identifier})
+ } else if strings.HasPrefix(identifier, "Party") {
+ events.AddToQueue(GMCPPartyUpdate{UserId: userId, Identifier: identifier})
+ } else if strings.HasPrefix(identifier, "Game") {
+ events.AddToQueue(GMCPGameUpdate{UserId: userId, Identifier: identifier})
+ } else if strings.HasPrefix(identifier, "Client.Map") {
+ if mudletHandler != nil && mudletHandler.isMudletClient(userId) {
+ mudletHandler.sendMudletMapConfig(userId)
+ }
+ } else if strings.HasPrefix(identifier, "Comm") {
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Comm.Channel`,
+ Payload: map[string]string{
+ "channel": "",
+ "sender": "",
+ "source": "",
+ "text": "",
+ },
+ })
}
}
-
- g.cache.Add(connectionId, gmcpData)
-
- }
- case `Char.Login`:
- decoded := GMCPLogin{}
- if err := json.Unmarshal(payload, &decoded); err == nil {
- mudlog.Debug("GMCP LOGIN", "username", decoded.Name, "password", strings.Repeat(`*`, len(decoded.Password)))
}
- // Handle Discord-related messages
default:
- // Check if it's a Discord message
if strings.HasPrefix(command, "External.Discord") {
// Try to find the user ID associated with this connection
userId := 0
@@ -377,20 +536,16 @@ func (g *GMCPModule) HandleIAC(connectionId uint64, iacCmd []byte) bool {
}
if userId > 0 {
- // Extract the Discord command (Hello, Get, Status)
discordCommand := ""
if parts := strings.Split(command, "."); len(parts) >= 3 {
- discordCommand = parts[2] // External.Discord.Hello -> Hello
+ discordCommand = parts[2] // Extract command: External.Discord.Hello -> Hello
}
-
- // Dispatch a GMCPDiscordMessage event
events.AddToQueue(GMCPDiscordMessage{
ConnectionId: connectionId,
Command: discordCommand,
Payload: payload,
})
- mudlog.Debug("GMCP", "type", "Discord", "command", discordCommand, "userId", userId)
}
}
}
@@ -398,13 +553,9 @@ func (g *GMCPModule) HandleIAC(connectionId uint64, iacCmd []byte) bool {
return true
}
- // Unhanlded IAC command, log it
- mudlog.Debug("Received", "type", "GMCP?", "data-size", len(iacCmd), "data-string", string(iacCmd), "data-bytes", iacCmd)
-
return true
}
-// Checks whether their level is too high for a guide
func (g *GMCPModule) dispatchGMCP(e events.Event) events.ListenerReturn {
gmcp, typeOk := e.(GMCPOut)
@@ -435,7 +586,6 @@ func (g *GMCPModule) dispatchGMCP(e events.Event) events.ListenerReturn {
return events.Continue
}
- // Get enabled modules... if none, skip out.
if !gmcpSettings.GMCPAccepted {
return events.Continue
}
@@ -449,16 +599,6 @@ func (g *GMCPModule) dispatchGMCP(e events.Event) events.ListenerReturn {
switch v := gmcp.Payload.(type) {
case []byte:
- // DEBUG ONLY
- // TODO: REMOVE
- if gmcp.UserId == 1 && os.Getenv("CONSOLE_GMCP_OUTPUT") == "1" {
- var prettyJSON bytes.Buffer
- json.Indent(&prettyJSON, v, "", "\t")
- fmt.Print(gmcp.Module + ` `)
- fmt.Println(prettyJSON.String())
- }
-
- // Regular code follows...
if len(gmcp.Module) > 0 {
v = append([]byte(gmcp.Module+` `), v...)
}
@@ -471,16 +611,6 @@ func (g *GMCPModule) dispatchGMCP(e events.Event) events.ListenerReturn {
case string:
- // DEBUG ONLY
- // TODO: REMOVE
- if gmcp.UserId == 1 && os.Getenv("CONSOLE_GMCP_OUTPUT") == "1" {
- var prettyJSON bytes.Buffer
- json.Indent(&prettyJSON, []byte(v), "", "\t")
- fmt.Print(gmcp.Module + ` `)
- fmt.Println(prettyJSON.String())
- }
-
- // Regular code follows...
if len(gmcp.Module) > 0 {
v = gmcp.Module + ` ` + v
}
@@ -498,16 +628,6 @@ func (g *GMCPModule) dispatchGMCP(e events.Event) events.ListenerReturn {
return events.Continue
}
- // DEBUG ONLY
- // TODO: REMOVE
- if gmcp.UserId == 1 && os.Getenv("CONSOLE_GMCP_OUTPUT") == "1" {
- var prettyJSON bytes.Buffer
- json.Indent(&prettyJSON, payload, "", "\t")
- fmt.Print(gmcp.Module + ` `)
- fmt.Println(prettyJSON.String())
- }
-
- // Regular code follows...
if len(gmcp.Module) > 0 {
payload = append([]byte(gmcp.Module+` `), payload...)
}
@@ -522,3 +642,194 @@ func (g *GMCPModule) dispatchGMCP(e events.Event) events.ListenerReturn {
return events.Continue
}
+
+// SendFullGMCPUpdate sends all GMCP modules data to a specific user
+// This is useful when a client needs to resync all GMCP data
+func SendFullGMCPUpdate(userId int) {
+ if userId < 1 {
+ return
+ }
+
+ user := users.GetByUserId(userId)
+ if user == nil {
+ return
+ }
+
+ if !isGMCPEnabled(user.ConnectionId()) {
+ return
+ }
+
+ events.AddToQueue(GMCPCharUpdate{UserId: userId, Identifier: `Char`})
+ events.AddToQueue(GMCPRoomUpdate{UserId: userId, Identifier: `Room`})
+
+ // Schema establishment - empty structures define expected fields for clients
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Room.Wrongdir`,
+ Payload: map[string]string{"dir": ""},
+ })
+
+ events.AddToQueue(GMCPPartyUpdate{UserId: userId, Identifier: `Party`})
+ events.AddToQueue(GMCPGameUpdate{UserId: userId, Identifier: `Game`})
+ events.AddToQueue(GMCPCombatStatusUpdate{UserId: userId})
+
+ events.AddToQueue(GMCPCombatTargetUpdate{UserId: userId})
+ events.AddToQueue(GMCPCombatEnemiesUpdate{UserId: userId})
+
+ timingConfig := configs.GetTimingConfig()
+ events.AddToQueue(GMCPCombatCooldownUpdate{
+ UserId: userId,
+ CooldownSeconds: 0.0,
+ MaxSeconds: float64(timingConfig.RoundSeconds),
+ NameActive: "Combat Round",
+ NameIdle: "Ready",
+ })
+
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Char.Combat.Damage`,
+ Payload: map[string]interface{}{
+ "amount": 0,
+ "type": "",
+ "source": "",
+ "target": "",
+ },
+ })
+
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Room.Remove.Player`,
+ Payload: map[string]string{"name": ""},
+ })
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Room.Remove.Npc`,
+ Payload: map[string]interface{}{"id": "", "name": ""},
+ })
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Room.Remove.Item`,
+ Payload: map[string]interface{}{"id": "", "name": ""},
+ })
+
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Room.Add.Player`,
+ Payload: map[string]string{"name": ""},
+ })
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Room.Add.Npc`,
+ Payload: map[string]interface{}{
+ "id": "",
+ "name": "",
+ "threat_level": "",
+ "targeting_you": false,
+ },
+ })
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Room.Add.Item`,
+ Payload: map[string]interface{}{
+ "id": "", "name": "", "quest_flag": false,
+ },
+ })
+
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Char.Combat.Started`,
+ Payload: map[string]interface{}{
+ "role": "",
+ "target_id": 0,
+ "target_type": "",
+ "target_name": "",
+ "initiated_by": "",
+ },
+ })
+
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Char.Combat.Ended`,
+ Payload: map[string]interface{}{
+ "reason": "",
+ "duration": 0,
+ },
+ })
+
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Char.Combat.DamageDealt`,
+ Payload: map[string]interface{}{
+ "target_id": 0,
+ "target_type": "",
+ "target_name": "",
+ "amount": 0,
+ "damage_type": "",
+ "weapon_name": "",
+ "spell_name": "",
+ "is_critical": false,
+ "is_killing_blow": false,
+ },
+ })
+
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Char.Combat.DamageReceived`,
+ Payload: map[string]interface{}{
+ "source_id": 0,
+ "source_type": "",
+ "source_name": "",
+ "amount": 0,
+ "damage_type": "",
+ "weapon_name": "",
+ "spell_name": "",
+ "is_critical": false,
+ "is_killing_blow": false,
+ },
+ })
+
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Char.Combat.AttackMissed`,
+ Payload: map[string]interface{}{
+ "defender_id": 0,
+ "defender_type": "",
+ "defender_name": "",
+ "avoid_type": "",
+ "weapon_name": "",
+ },
+ })
+
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Char.Combat.AttackAvoided`,
+ Payload: map[string]interface{}{
+ "attacker_id": 0,
+ "attacker_type": "",
+ "attacker_name": "",
+ "avoid_type": "",
+ "weapon_name": "",
+ },
+ })
+
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Char.Combat.Fled`,
+ Payload: map[string]interface{}{
+ "direction": "",
+ "success": false,
+ "prevented_by": "",
+ },
+ })
+
+ events.AddToQueue(GMCPOut{
+ UserId: userId,
+ Module: `Comm.Channel`,
+ Payload: map[string]string{
+ "channel": "",
+ "sender": "",
+ "source": "",
+ "text": "",
+ },
+ })
+}