diff --git a/internal/actions/move.go b/internal/actions/move.go index b76d35b..ea5c091 100644 --- a/internal/actions/move.go +++ b/internal/actions/move.go @@ -34,7 +34,7 @@ func (d Direction) String() string { func Move(user *users.User, dir Direction) (string, int, int, error) { control := control.Get() - newX, newY := user.X, user.Y + newX, newY := user.GetPosition() switch dir { case North: newY++ diff --git a/internal/models/fights/fights.go b/internal/models/fights/fights.go new file mode 100644 index 0000000..97a19ca --- /dev/null +++ b/internal/models/fights/fights.go @@ -0,0 +1,311 @@ +package fights + +import ( + "dk/internal/store" + "fmt" + "time" +) + +// Fight represents a fight, past or present +type Fight struct { + ID int `json:"id"` + UserID int `json:"user_id"` + MonsterID int `json:"monster_id"` + MonsterHP int `json:"monster_hp"` + MonsterMaxHP int `json:"monster_max_hp"` + MonsterSleep int `json:"monster_sleep"` + MonsterImmune int `json:"monster_immune"` + UberDamage int `json:"uber_damage"` + UberDefense int `json:"uber_defense"` + FirstStrike bool `json:"first_strike"` + Turn int `json:"turn"` + RanAway bool `json:"ran_away"` + Victory bool `json:"victory"` + Won bool `json:"won"` + RewardGold int `json:"reward_gold"` + RewardExp int `json:"reward_exp"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` +} + +func (f *Fight) Save() error { + f.Updated = time.Now().Unix() + return GetStore().UpdateWithRebuild(f.ID, f) +} + +func (f *Fight) Delete() error { + GetStore().RemoveWithRebuild(f.ID) + return nil +} + +func New(userID, monsterID int) *Fight { + now := time.Now().Unix() + return &Fight{ + UserID: userID, + MonsterID: monsterID, + MonsterHP: 0, + MonsterMaxHP: 0, + MonsterSleep: 0, + MonsterImmune: 0, + UberDamage: 0, + UberDefense: 0, + FirstStrike: false, + Turn: 1, + RanAway: false, + Victory: false, + Won: false, + RewardGold: 0, + RewardExp: 0, + Created: now, + Updated: now, + } +} + +// Validate checks if fight has valid values +func (f *Fight) Validate() error { + if f.UserID <= 0 { + return fmt.Errorf("fight UserID must be positive") + } + if f.MonsterID <= 0 { + return fmt.Errorf("fight MonsterID must be positive") + } + if f.Turn < 1 { + return fmt.Errorf("fight Turn must be at least 1") + } + if f.MonsterHP < 0 { + return fmt.Errorf("fight MonsterHP cannot be negative") + } + if f.Created <= 0 { + return fmt.Errorf("fight Created timestamp must be positive") + } + if f.Updated <= 0 { + return fmt.Errorf("fight Updated timestamp must be positive") + } + return nil +} + +// FightStore with enhanced BaseStore +type FightStore struct { + *store.BaseStore[Fight] +} + +// Global store with singleton pattern +var GetStore = store.NewSingleton(func() *FightStore { + fs := &FightStore{BaseStore: store.NewBaseStore[Fight]()} + + // Register indices + fs.RegisterIndex("byUserID", store.BuildIntGroupIndex(func(f *Fight) int { + return f.UserID + })) + + fs.RegisterIndex("byMonsterID", store.BuildIntGroupIndex(func(f *Fight) int { + return f.MonsterID + })) + + fs.RegisterIndex("activeFights", store.BuildFilteredIntGroupIndex( + func(f *Fight) bool { + return !f.RanAway && !f.Victory + }, + func(f *Fight) int { + return f.UserID + }, + )) + + fs.RegisterIndex("allByCreated", store.BuildSortedListIndex(func(a, b *Fight) bool { + if a.Created != b.Created { + return a.Created > b.Created // DESC + } + return a.ID > b.ID // DESC + })) + + fs.RegisterIndex("allByUpdated", store.BuildSortedListIndex(func(a, b *Fight) bool { + if a.Updated != b.Updated { + return a.Updated > b.Updated // DESC + } + return a.ID > b.ID // DESC + })) + + return fs +}) + +// Enhanced CRUD operations +func (fs *FightStore) AddFight(fight *Fight) error { + return fs.AddWithRebuild(fight.ID, fight) +} + +func (fs *FightStore) RemoveFight(id int) { + fs.RemoveWithRebuild(id) +} + +func (fs *FightStore) UpdateFight(fight *Fight) error { + return fs.UpdateWithRebuild(fight.ID, fight) +} + +// Data persistence +func LoadData(dataPath string) error { + fs := GetStore() + return fs.BaseStore.LoadData(dataPath) +} + +func SaveData(dataPath string) error { + fs := GetStore() + return fs.BaseStore.SaveData(dataPath) +} + +// Query functions using enhanced store +func Find(id int) (*Fight, error) { + fs := GetStore() + fight, exists := fs.Find(id) + if !exists { + return nil, fmt.Errorf("fight with ID %d not found", id) + } + return fight, nil +} + +func All() ([]*Fight, error) { + fs := GetStore() + return fs.AllSorted("allByCreated"), nil +} + +func ByUserID(userID int) ([]*Fight, error) { + fs := GetStore() + return fs.GroupByIndex("byUserID", userID), nil +} + +func ByMonsterID(monsterID int) ([]*Fight, error) { + fs := GetStore() + return fs.GroupByIndex("byMonsterID", monsterID), nil +} + +func ActiveByUserID(userID int) ([]*Fight, error) { + fs := GetStore() + return fs.GroupByIndex("activeFights", userID), nil +} + +func Active() ([]*Fight, error) { + fs := GetStore() + result := fs.FilterByIndex("allByCreated", func(f *Fight) bool { + return !f.RanAway && !f.Victory + }) + return result, nil +} + +func Recent(within time.Duration) ([]*Fight, error) { + fs := GetStore() + cutoff := time.Now().Add(-within).Unix() + + result := fs.FilterByIndex("allByCreated", func(f *Fight) bool { + return f.Created >= cutoff + }) + + return result, nil +} + +// Insert with ID assignment +func (f *Fight) Insert() error { + fs := GetStore() + if f.ID == 0 { + f.ID = fs.GetNextID() + } + f.Updated = time.Now().Unix() + return fs.AddFight(f) +} + +// Helper methods +func (f *Fight) CreatedTime() time.Time { + return time.Unix(f.Created, 0) +} + +func (f *Fight) UpdatedTime() time.Time { + return time.Unix(f.Updated, 0) +} + +func (f *Fight) IsActive() bool { + return !f.RanAway && !f.Victory +} + +func (f *Fight) IsComplete() bool { + return f.RanAway || f.Victory +} + +func (f *Fight) GetStatus() string { + if f.Won { + return "Won" + } + if f.Victory && !f.Won { + return "Lost" + } + if f.RanAway { + return "Ran Away" + } + return "Active" +} + +func (f *Fight) GetMonsterHealthPercent() float64 { + if f.MonsterMaxHP <= 0 { + return 0.0 + } + return float64(f.MonsterHP) / float64(f.MonsterMaxHP) * 100.0 +} + +func (f *Fight) IsMonsterSleeping() bool { + return f.MonsterSleep > 0 +} + +func (f *Fight) IsMonsterImmune() bool { + return f.MonsterImmune > 0 +} + +func (f *Fight) HasUberBonus() bool { + return f.UberDamage > 0 || f.UberDefense > 0 +} + +func (f *Fight) GetDuration() time.Duration { + return time.Unix(f.Updated, 0).Sub(time.Unix(f.Created, 0)) +} + +func (f *Fight) EndFight(victory bool) { + f.Victory = victory + f.RanAway = !victory + f.Updated = time.Now().Unix() +} + +func (f *Fight) WinFight(goldReward, expReward int) { + f.Victory = true + f.Won = true + f.RanAway = false + f.RewardGold = goldReward + f.RewardExp = expReward + f.Updated = time.Now().Unix() +} + +func (f *Fight) LoseFight() { + f.Victory = true + f.Won = false + f.RanAway = false + f.Updated = time.Now().Unix() +} + +func (f *Fight) RunAway() { + f.RanAway = true + f.Victory = false + f.Updated = time.Now().Unix() +} + +func (f *Fight) IncrementTurn() { + f.Turn++ + f.Updated = time.Now().Unix() +} + +func (f *Fight) SetMonsterHP(hp int) { + f.MonsterHP = hp + f.Updated = time.Now().Unix() +} + +func (f *Fight) DamageMonster(damage int) { + f.MonsterHP -= damage + if f.MonsterHP < 0 { + f.MonsterHP = 0 + } + f.Updated = time.Now().Unix() +} diff --git a/internal/models/users/users.go b/internal/models/users/users.go index 2e131aa..41e5140 100644 --- a/internal/models/users/users.go +++ b/internal/models/users/users.go @@ -13,56 +13,49 @@ import ( // User represents a user in the game type User struct { - ID int `json:"id"` - Username string `json:"username"` - Password string `json:"password"` - Email string `json:"email"` - Verified int `json:"verified"` - Token string `json:"token"` - Registered int64 `json:"registered"` - LastOnline int64 `json:"last_online"` - Auth int `json:"auth"` - X int `json:"x"` - Y int `json:"y"` - ClassID int `json:"class_id"` - Currently string `json:"currently"` - Fighting int `json:"fighting"` - MonsterID int `json:"monster_id"` - MonsterHP int `json:"monster_hp"` - MonsterSleep int `json:"monster_sleep"` - MonsterImmune int `json:"monster_immune"` - UberDamage int `json:"uber_damage"` - UberDefense int `json:"uber_defense"` - HP int `json:"hp"` - MP int `json:"mp"` - TP int `json:"tp"` - MaxHP int `json:"max_hp"` - MaxMP int `json:"max_mp"` - MaxTP int `json:"max_tp"` - Level int `json:"level"` - Gold int `json:"gold"` - Exp int `json:"exp"` - GoldBonus int `json:"gold_bonus"` - ExpBonus int `json:"exp_bonus"` - Strength int `json:"strength"` - Dexterity int `json:"dexterity"` - Attack int `json:"attack"` - Defense int `json:"defense"` - WeaponID int `json:"weapon_id"` - ArmorID int `json:"armor_id"` - ShieldID int `json:"shield_id"` - Slot1ID int `json:"slot_1_id"` - Slot2ID int `json:"slot_2_id"` - Slot3ID int `json:"slot_3_id"` - WeaponName string `json:"weapon_name"` - ArmorName string `json:"armor_name"` - ShieldName string `json:"shield_name"` - Slot1Name string `json:"slot_1_name"` - Slot2Name string `json:"slot_2_name"` - Slot3Name string `json:"slot_3_name"` - DropCode int `json:"drop_code"` - Spells string `json:"spells"` - Towns string `json:"towns"` + ID int `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + Verified int `json:"verified"` + Token string `json:"token"` + Registered int64 `json:"registered"` + LastOnline int64 `json:"last_online"` + Auth int `json:"auth"` + X int `json:"x"` + Y int `json:"y"` + ClassID int `json:"class_id"` + Currently string `json:"currently"` + FightID int `json:"fight_id"` + HP int `json:"hp"` + MP int `json:"mp"` + TP int `json:"tp"` + MaxHP int `json:"max_hp"` + MaxMP int `json:"max_mp"` + MaxTP int `json:"max_tp"` + Level int `json:"level"` + Gold int `json:"gold"` + Exp int `json:"exp"` + GoldBonus int `json:"gold_bonus"` + ExpBonus int `json:"exp_bonus"` + Strength int `json:"strength"` + Dexterity int `json:"dexterity"` + Attack int `json:"attack"` + Defense int `json:"defense"` + WeaponID int `json:"weapon_id"` + ArmorID int `json:"armor_id"` + ShieldID int `json:"shield_id"` + Slot1ID int `json:"slot_1_id"` + Slot2ID int `json:"slot_2_id"` + Slot3ID int `json:"slot_3_id"` + WeaponName string `json:"weapon_name"` + ArmorName string `json:"armor_name"` + ShieldName string `json:"shield_name"` + Slot1Name string `json:"slot_1_name"` + Slot2Name string `json:"slot_2_name"` + Slot3Name string `json:"slot_3_name"` + Spells string `json:"spells"` + Towns string `json:"towns"` } func (u *User) Save() error { @@ -86,7 +79,7 @@ func New() *User { Y: 0, ClassID: 1, Currently: "In Town", - Fighting: 0, + FightID: 0, HP: 10, MP: 10, TP: 10, @@ -289,7 +282,7 @@ func (u *User) IsModerator() bool { } func (u *User) IsFighting() bool { - return u.Fighting == 1 + return u.FightID > 0 } func (u *User) IsAlive() bool { @@ -333,19 +326,17 @@ func (u *User) GetEquipment() map[string]any { func (u *User) GetStats() map[string]int { return map[string]int{ - "level": u.Level, - "hp": u.HP, - "mp": u.MP, - "tp": u.TP, - "max_hp": u.MaxHP, - "max_mp": u.MaxMP, - "max_tp": u.MaxTP, - "strength": u.Strength, - "dexterity": u.Dexterity, - "attack": u.Attack, - "defense": u.Defense, - "uber_damage": u.UberDamage, - "uber_defense": u.UberDefense, + "level": u.Level, + "hp": u.HP, + "mp": u.MP, + "tp": u.TP, + "max_hp": u.MaxHP, + "max_mp": u.MaxMP, + "max_tp": u.MaxTP, + "strength": u.Strength, + "dexterity": u.Dexterity, + "attack": u.Attack, + "defense": u.Defense, } } diff --git a/internal/routes/index.go b/internal/routes/index.go index 91526f4..b168df0 100644 --- a/internal/routes/index.go +++ b/internal/routes/index.go @@ -45,7 +45,7 @@ func Move(ctx router.Ctx, _ []string) { } user.Currently = currently - user.X, user.Y = newX, newY + user.SetPosition(newX, newY) if currently == "In Town" { ctx.Redirect("/town", 303) @@ -95,7 +95,7 @@ func Teleport(ctx router.Ctx, params []string) { } user.TP -= town.TPCost - user.X, user.Y = town.X, town.Y + user.SetPosition(town.X, town.Y) user.Currently = "In Town" user.Save() diff --git a/internal/store/store.go b/internal/store/store.go index 94230da..a3c4394 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,9 +1,9 @@ package store import ( - "maps" "encoding/json" "fmt" + "maps" "os" "path/filepath" "reflect" @@ -484,3 +484,43 @@ func (bs *BaseStore[T]) SaveData(dataPath string) error { fmt.Printf("Saved %d items to %s\n", len(bs.items), dataPath) return nil } + +// BuildFilteredIntGroupIndex creates int-to-[]ID mapping for items passing filter +func BuildFilteredIntGroupIndex[T any](filterFunc func(*T) bool, keyFunc func(*T) int) IndexBuilder[T] { + return func(allItems map[int]*T) any { + index := make(map[int][]int) + for id, item := range allItems { + if filterFunc(item) { + key := keyFunc(item) + index[key] = append(index[key], id) + } + } + + // Sort each group by ID + for key := range index { + sort.Ints(index[key]) + } + + return index + } +} + +// BuildFilteredStringGroupIndex creates string-to-[]ID mapping for items passing filter +func BuildFilteredStringGroupIndex[T any](filterFunc func(*T) bool, keyFunc func(*T) string) IndexBuilder[T] { + return func(allItems map[int]*T) any { + index := make(map[string][]int) + for id, item := range allItems { + if filterFunc(item) { + key := keyFunc(item) + index[key] = append(index[key], id) + } + } + + // Sort each group by ID + for key := range index { + sort.Ints(index[key]) + } + + return index + } +} diff --git a/main.go b/main.go index 1bb81ab..bdd6453 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "dk/internal/models/babble" "dk/internal/models/control" "dk/internal/models/drops" + "dk/internal/models/fights" "dk/internal/models/forum" "dk/internal/models/items" "dk/internal/models/monsters" @@ -52,9 +53,7 @@ func main() { } } -func loadModels() error { - dataDir := "data" - +func loadModels(dataDir string) error { if err := os.MkdirAll(dataDir, 0755); err != nil { return fmt.Errorf("failed to create data directory: %w", err) } @@ -99,12 +98,14 @@ func loadModels() error { return fmt.Errorf("failed to load control data: %w", err) } + if err := fights.LoadData(filepath.Join(dataDir, "fights.json")); err != nil { + return fmt.Errorf("failed to load fights data: %w", err) + } + return nil } -func saveModels() error { - dataDir := "data" - +func saveModels(dataDir string) error { if err := users.SaveData(filepath.Join(dataDir, "users.json")); err != nil { return fmt.Errorf("failed to save users data: %w", err) } @@ -145,6 +146,10 @@ func saveModels() error { return fmt.Errorf("failed to save control data: %w", err) } + if err := fights.SaveData(filepath.Join(dataDir, "fights.json")); err != nil { + return fmt.Errorf("failed to save fights data: %w", err) + } + return nil } @@ -163,11 +168,11 @@ func start(port string) error { template.InitializeCache(cwd) - if err := loadModels(); err != nil { + if err := loadModels(filepath.Join(cwd, "data")); err != nil { return fmt.Errorf("failed to load models: %w", err) } - session.Init("data/_sessions.json") + session.Init(filepath.Join(cwd, "data/_sessions.json")) r := router.New() r.Use(middleware.Timing()) @@ -238,7 +243,7 @@ func start(port string) error { // Save all model data before shutdown log.Println("Saving model data...") - if err := saveModels(); err != nil { + if err := saveModels(filepath.Join(cwd, "data")); err != nil { log.Printf("Error saving model data: %v", err) }