diff --git a/_datafiles/html/public/webclient-pure.html b/_datafiles/html/public/webclient-pure.html index 4189e4d7..8ba775c1 100644 --- a/_datafiles/html/public/webclient-pure.html +++ b/_datafiles/html/public/webclient-pure.html @@ -1,1255 +1,1447 @@ - + - + {{ .CONFIG.Server.MudName }} Web Terminal - + - - - + +
-

Volume Controls

- -
- - -
- -
- -
+

Volume Controls

+ +
+ + +
+ +
+ +
-
+
- +
- - - - - -
- - -
-
-
- 100% -
-
-
- 100% -
-
- -
- - -
-
- - - - -
-
-
-
-
-
-
+
+ +
+
+
+ 100%
- +
+
+ 100% +
+
+ + +
+ + +
+
+ + + + +
+
+
+
+
+
+
+
- - + 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": "", + }, + }) +}