// Package ground_spawn provides harvestable resource node management for EQ2. // // Basic Usage: // // gs := ground_spawn.New(db) // gs.CollectionSkill = "Mining" // gs.NumberHarvests = 5 // gs.Save() // // loaded, _ := ground_spawn.Load(db, 1001) // result, _ := loaded.ProcessHarvest(context) // // Master List: // // masterList := ground_spawn.NewMasterList() // masterList.Add(gs) package ground_spawn import ( "fmt" "math/rand" "strings" "sync" "time" "eq2emu/internal/database" "zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite/sqlitex" ) // GroundSpawn represents a harvestable resource node with embedded database operations type GroundSpawn struct { // Database fields ID int32 `json:"id" db:"id"` // Auto-generated ID GroundSpawnID int32 `json:"groundspawn_id" db:"groundspawn_id"` // Entry ID for this type of ground spawn Name string `json:"name" db:"name"` // Display name CollectionSkill string `json:"collection_skill" db:"collection_skill"` // Required skill (Mining, Gathering, etc.) NumberHarvests int8 `json:"number_harvests" db:"number_harvests"` // Harvests before depletion AttemptsPerHarvest int8 `json:"attempts_per_harvest" db:"attempts_per_harvest"` // Attempts per harvest session RandomizeHeading bool `json:"randomize_heading" db:"randomize_heading"` // Randomize spawn heading RespawnTime int32 `json:"respawn_time" db:"respawn_time"` // Respawn time in seconds // Position data X float32 `json:"x" db:"x"` // World X coordinate Y float32 `json:"y" db:"y"` // World Y coordinate Z float32 `json:"z" db:"z"` // World Z coordinate Heading float32 `json:"heading" db:"heading"` // Spawn heading/rotation ZoneID int32 `json:"zone_id" db:"zone_id"` // Zone identifier GridID int32 `json:"grid_id" db:"grid_id"` // Grid identifier // State data IsAlive bool `json:"is_alive"` // Whether spawn is active CurrentHarvests int8 `json:"current_harvests"` // Current harvest count LastHarvested time.Time `json:"last_harvested"` // When last harvested NextRespawn time.Time `json:"next_respawn"` // When it will respawn // Associated data (loaded separately) HarvestEntries []*HarvestEntry `json:"harvest_entries,omitempty"` HarvestItems []*HarvestEntryItem `json:"harvest_items,omitempty"` // Database connection and internal state db *database.Database `json:"-"` isNew bool `json:"-"` harvestMux sync.Mutex `json:"-"` } // New creates a new ground spawn with database connection func New(db *database.Database) *GroundSpawn { return &GroundSpawn{ HarvestEntries: make([]*HarvestEntry, 0), HarvestItems: make([]*HarvestEntryItem, 0), db: db, isNew: true, IsAlive: true, CurrentHarvests: 0, NumberHarvests: 5, // Default AttemptsPerHarvest: 1, // Default RandomizeHeading: true, } } // Load loads a ground spawn by ID from database func Load(db *database.Database, groundSpawnID int32) (*GroundSpawn, error) { gs := &GroundSpawn{ db: db, isNew: false, } if db.GetType() == database.SQLite { err := db.ExecTransient(` SELECT id, groundspawn_id, name, collection_skill, number_harvests, attempts_per_harvest, randomize_heading, respawn_time, x, y, z, heading, zone_id, grid_id FROM ground_spawns WHERE groundspawn_id = ? `, func(stmt *sqlite.Stmt) error { gs.ID = stmt.ColumnInt32(0) gs.GroundSpawnID = stmt.ColumnInt32(1) gs.Name = stmt.ColumnText(2) gs.CollectionSkill = stmt.ColumnText(3) gs.NumberHarvests = int8(stmt.ColumnInt32(4)) gs.AttemptsPerHarvest = int8(stmt.ColumnInt32(5)) gs.RandomizeHeading = stmt.ColumnBool(6) gs.RespawnTime = stmt.ColumnInt32(7) gs.X = float32(stmt.ColumnFloat(8)) gs.Y = float32(stmt.ColumnFloat(9)) gs.Z = float32(stmt.ColumnFloat(10)) gs.Heading = float32(stmt.ColumnFloat(11)) gs.ZoneID = stmt.ColumnInt32(12) gs.GridID = stmt.ColumnInt32(13) return nil }, groundSpawnID) if err != nil { return nil, fmt.Errorf("ground spawn not found: %d", groundSpawnID) } } else { // MySQL implementation row := db.QueryRow(` SELECT id, groundspawn_id, name, collection_skill, number_harvests, attempts_per_harvest, randomize_heading, respawn_time, x, y, z, heading, zone_id, grid_id FROM ground_spawns WHERE groundspawn_id = ? `, groundSpawnID) err := row.Scan(&gs.ID, &gs.GroundSpawnID, &gs.Name, &gs.CollectionSkill, &gs.NumberHarvests, &gs.AttemptsPerHarvest, &gs.RandomizeHeading, &gs.RespawnTime, &gs.X, &gs.Y, &gs.Z, &gs.Heading, &gs.ZoneID, &gs.GridID) if err != nil { return nil, fmt.Errorf("ground spawn not found: %d", groundSpawnID) } } // Initialize state gs.IsAlive = true gs.CurrentHarvests = gs.NumberHarvests // Load harvest entries and items if err := gs.loadHarvestData(); err != nil { return nil, fmt.Errorf("failed to load harvest data: %w", err) } return gs, nil } // Save saves the ground spawn to database func (gs *GroundSpawn) Save() error { if gs.db == nil { return fmt.Errorf("no database connection") } if gs.isNew { return gs.insert() } return gs.update() } // Delete removes the ground spawn from database func (gs *GroundSpawn) Delete() error { if gs.db == nil { return fmt.Errorf("no database connection") } if gs.isNew { return fmt.Errorf("cannot delete unsaved ground spawn") } if gs.db.GetType() == database.SQLite { return gs.db.Execute("DELETE FROM ground_spawns WHERE groundspawn_id = ?", &sqlitex.ExecOptions{Args: []any{gs.GroundSpawnID}}) } _, err := gs.db.Exec("DELETE FROM ground_spawns WHERE groundspawn_id = ?", gs.GroundSpawnID) return err } // GetID returns the ground spawn ID (implements common.Identifiable) func (gs *GroundSpawn) GetID() int32 { return gs.GroundSpawnID } // IsGroundSpawn returns true (compatibility method) func (gs *GroundSpawn) IsGroundSpawn() bool { return true } // IsDepleted returns true if the ground spawn has no harvests remaining func (gs *GroundSpawn) IsDepleted() bool { return gs.CurrentHarvests <= 0 } // IsAvailable returns true if the ground spawn can be harvested func (gs *GroundSpawn) IsAvailable() bool { return gs.CurrentHarvests > 0 && gs.IsAlive } // GetHarvestMessageName returns the appropriate harvest verb based on skill func (gs *GroundSpawn) GetHarvestMessageName(presentTense bool, failure bool) string { skill := strings.ToLower(gs.CollectionSkill) switch skill { case "gathering", "collecting": if presentTense { return "gather" } return "gathered" case "mining": if presentTense { return "mine" } return "mined" case "fishing": if presentTense { return "fish" } return "fished" case "trapping": if failure { return "trap" } if presentTense { return "acquire" } return "acquired" case "foresting": if presentTense { return "forest" } return "forested" default: if presentTense { return "collect" } return "collected" } } // GetHarvestSpellType returns the spell type for harvesting func (gs *GroundSpawn) GetHarvestSpellType() string { skill := strings.ToLower(gs.CollectionSkill) switch skill { case "gathering", "collecting": return SpellTypeGather case "mining": return SpellTypeMine case "trapping": return SpellTypeTrap case "foresting": return SpellTypeChop case "fishing": return SpellTypeFish default: return SpellTypeGather } } // GetHarvestSpellName returns the spell name for harvesting func (gs *GroundSpawn) GetHarvestSpellName() string { skill := gs.CollectionSkill if skill == SkillCollecting { return SkillGathering } return skill } // ProcessHarvest handles the complex harvesting logic (preserves C++ algorithm) func (gs *GroundSpawn) ProcessHarvest(player Player, skill Skill, totalSkill int16) (*HarvestResult, error) { gs.harvestMux.Lock() defer gs.harvestMux.Unlock() // Check if ground spawn is depleted if gs.CurrentHarvests <= 0 { return &HarvestResult{ Success: false, MessageText: "This spawn has nothing more to harvest!", }, nil } // Validate harvest data if len(gs.HarvestEntries) == 0 { return &HarvestResult{ Success: false, MessageText: fmt.Sprintf("Error: No groundspawn entries assigned to groundspawn id: %d", gs.GroundSpawnID), }, nil } if len(gs.HarvestItems) == 0 { return &HarvestResult{ Success: false, MessageText: fmt.Sprintf("Error: No groundspawn items assigned to groundspawn id: %d", gs.GroundSpawnID), }, nil } // Check for collection skill isCollection := gs.CollectionSkill == "Collecting" result := &HarvestResult{ Success: true, ItemsAwarded: make([]*HarvestedItem, 0), } // Process each harvest attempt (preserving C++ logic) for attempt := int8(0); attempt < gs.AttemptsPerHarvest; attempt++ { attemptResult := gs.processHarvestAttempt(player, skill, totalSkill, isCollection) if attemptResult != nil { result.ItemsAwarded = append(result.ItemsAwarded, attemptResult.ItemsAwarded...) if attemptResult.SkillGained { result.SkillGained = true } } } // Decrement harvest count and update state gs.CurrentHarvests-- gs.LastHarvested = time.Now() return result, nil } // processHarvestAttempt handles a single harvest attempt (preserves C++ algorithm) func (gs *GroundSpawn) processHarvestAttempt(player Player, skill Skill, totalSkill int16, isCollection bool) *HarvestResult { // Filter available harvest tables based on player skill and level availableTables := gs.filterHarvestTables(player, totalSkill) if len(availableTables) == 0 { return &HarvestResult{ Success: false, MessageText: "You lack the skills to harvest this node!", } } // Select harvest table based on skill roll (matching C++ algorithm) selectedTable := gs.selectHarvestTable(availableTables, totalSkill) if selectedTable == nil { return &HarvestResult{ Success: false, MessageText: "Failed to determine harvest table", } } // Determine harvest type based on table percentages harvestType := gs.determineHarvestType(selectedTable, isCollection) if harvestType == HarvestTypeNone { return &HarvestResult{ Success: false, MessageText: fmt.Sprintf("You failed to %s anything from %s.", gs.GetHarvestMessageName(true, true), gs.Name), } } // Award items based on harvest type items := gs.awardHarvestItems(harvestType, player) // Handle skill progression (simplified for now) skillGained := false // TODO: Implement skill progression return &HarvestResult{ Success: len(items) > 0, HarvestType: harvestType, ItemsAwarded: items, SkillGained: skillGained, } } // filterHarvestTables filters tables based on player capabilities (preserves C++ logic) func (gs *GroundSpawn) filterHarvestTables(player Player, totalSkill int16) []*HarvestEntry { var filtered []*HarvestEntry for _, entry := range gs.HarvestEntries { // Check skill requirement if entry.MinSkillLevel > totalSkill { continue } // Check level requirement for bonus tables if entry.BonusTable && player.GetLevel() < entry.MinAdventureLevel { continue } filtered = append(filtered, entry) } return filtered } // selectHarvestTable selects a harvest table based on skill level (preserves C++ algorithm) func (gs *GroundSpawn) selectHarvestTable(tables []*HarvestEntry, totalSkill int16) *HarvestEntry { if len(tables) == 0 { return nil } // Find lowest skill requirement (matching C++ logic) lowestSkill := int16(32767) for _, table := range tables { if table.MinSkillLevel < lowestSkill { lowestSkill = table.MinSkillLevel } } // Roll for table selection (matching C++ MakeRandomInt) tableChoice := int16(rand.Intn(int(totalSkill-lowestSkill+1))) + lowestSkill // Find best matching table (matching C++ logic) var bestTable *HarvestEntry bestScore := int16(0) for _, table := range tables { if tableChoice >= table.MinSkillLevel && table.MinSkillLevel > bestScore { bestTable = table bestScore = table.MinSkillLevel } } // If multiple tables match, pick randomly (matching C++ logic) var matches []*HarvestEntry for _, table := range tables { if table.MinSkillLevel == bestScore { matches = append(matches, table) } } if len(matches) > 1 { return matches[rand.Intn(len(matches))] } return bestTable } // determineHarvestType determines what type of harvest occurs (preserves C++ algorithm) func (gs *GroundSpawn) determineHarvestType(table *HarvestEntry, isCollection bool) int8 { chance := rand.Float32() * 100.0 // Collection items always get 1 item (matching C++ logic) if isCollection { return HarvestType1Item } // Check harvest types in order of rarity (matching C++ order) if chance <= table.Harvest10 { return HarvestType10AndRare } if chance <= table.HarvestRare { return HarvestTypeRare } if chance <= table.HarvestImbue { return HarvestTypeImbue } if chance <= table.Harvest5 { return HarvestType5Items } if chance <= table.Harvest3 { return HarvestType3Items } if chance <= table.Harvest1 { return HarvestType1Item } return HarvestTypeNone } // awardHarvestItems awards items based on harvest type (preserves C++ algorithm) func (gs *GroundSpawn) awardHarvestItems(harvestType int8, player Player) []*HarvestedItem { var items []*HarvestedItem // Filter items based on harvest type and player location (matching C++ logic) normalItems := gs.filterItems(gs.HarvestItems, ItemRarityNormal, player.GetLocation()) rareItems := gs.filterItems(gs.HarvestItems, ItemRarityRare, player.GetLocation()) imbueItems := gs.filterItems(gs.HarvestItems, ItemRarityImbue, player.GetLocation()) switch harvestType { case HarvestType1Item: items = gs.selectRandomItems(normalItems, 1) case HarvestType3Items: items = gs.selectRandomItems(normalItems, 3) case HarvestType5Items: items = gs.selectRandomItems(normalItems, 5) case HarvestTypeImbue: items = gs.selectRandomItems(imbueItems, 1) case HarvestTypeRare: items = gs.selectRandomItems(rareItems, 1) case HarvestType10AndRare: normal := gs.selectRandomItems(normalItems, 10) rare := gs.selectRandomItems(rareItems, 1) items = append(normal, rare...) } return items } // filterItems filters items by rarity and grid restriction (preserves C++ logic) func (gs *GroundSpawn) filterItems(items []*HarvestEntryItem, rarity int8, playerGrid int32) []*HarvestEntryItem { var filtered []*HarvestEntryItem for _, item := range items { if item.IsRare != rarity { continue } // Check grid restriction (matching C++ logic) if item.GridID != 0 && item.GridID != playerGrid { continue } filtered = append(filtered, item) } return filtered } // selectRandomItems randomly selects items from available list (preserves C++ logic) func (gs *GroundSpawn) selectRandomItems(items []*HarvestEntryItem, quantity int16) []*HarvestedItem { if len(items) == 0 { return nil } var result []*HarvestedItem for i := int16(0); i < quantity; i++ { selectedItem := items[rand.Intn(len(items))] harvestedItem := &HarvestedItem{ ItemID: selectedItem.ItemID, Quantity: selectedItem.Quantity, IsRare: selectedItem.IsRare == ItemRarityRare, Name: fmt.Sprintf("Item_%d", selectedItem.ItemID), // Placeholder for item name lookup } result = append(result, harvestedItem) } return result } // Respawn resets the ground spawn to harvestable state func (gs *GroundSpawn) Respawn() { gs.harvestMux.Lock() defer gs.harvestMux.Unlock() // Reset harvest count to default gs.CurrentHarvests = gs.NumberHarvests // Randomize heading if configured if gs.RandomizeHeading { gs.Heading = rand.Float32() * 360.0 } // Mark as alive and update times gs.IsAlive = true gs.NextRespawn = time.Time{} // Clear next respawn time } // Private database helper methods func (gs *GroundSpawn) insert() error { if gs.db.GetType() == database.SQLite { return gs.db.Execute(` INSERT INTO ground_spawns ( groundspawn_id, name, collection_skill, number_harvests, attempts_per_harvest, randomize_heading, respawn_time, x, y, z, heading, zone_id, grid_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, &sqlitex.ExecOptions{ Args: []any{gs.GroundSpawnID, gs.Name, gs.CollectionSkill, gs.NumberHarvests, gs.AttemptsPerHarvest, gs.RandomizeHeading, gs.RespawnTime, gs.X, gs.Y, gs.Z, gs.Heading, gs.ZoneID, gs.GridID}, }) } // MySQL _, err := gs.db.Exec(` INSERT INTO ground_spawns ( groundspawn_id, name, collection_skill, number_harvests, attempts_per_harvest, randomize_heading, respawn_time, x, y, z, heading, zone_id, grid_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, gs.GroundSpawnID, gs.Name, gs.CollectionSkill, gs.NumberHarvests, gs.AttemptsPerHarvest, gs.RandomizeHeading, gs.RespawnTime, gs.X, gs.Y, gs.Z, gs.Heading, gs.ZoneID, gs.GridID) if err == nil { gs.isNew = false } return err } func (gs *GroundSpawn) update() error { if gs.db.GetType() == database.SQLite { return gs.db.Execute(` UPDATE ground_spawns SET name = ?, collection_skill = ?, number_harvests = ?, attempts_per_harvest = ?, randomize_heading = ?, respawn_time = ?, x = ?, y = ?, z = ?, heading = ?, zone_id = ?, grid_id = ? WHERE groundspawn_id = ? `, &sqlitex.ExecOptions{ Args: []any{gs.Name, gs.CollectionSkill, gs.NumberHarvests, gs.AttemptsPerHarvest, gs.RandomizeHeading, gs.RespawnTime, gs.X, gs.Y, gs.Z, gs.Heading, gs.ZoneID, gs.GridID, gs.GroundSpawnID}, }) } // MySQL _, err := gs.db.Exec(` UPDATE ground_spawns SET name = ?, collection_skill = ?, number_harvests = ?, attempts_per_harvest = ?, randomize_heading = ?, respawn_time = ?, x = ?, y = ?, z = ?, heading = ?, zone_id = ?, grid_id = ? WHERE groundspawn_id = ? `, gs.Name, gs.CollectionSkill, gs.NumberHarvests, gs.AttemptsPerHarvest, gs.RandomizeHeading, gs.RespawnTime, gs.X, gs.Y, gs.Z, gs.Heading, gs.ZoneID, gs.GridID, gs.GroundSpawnID) return err } func (gs *GroundSpawn) loadHarvestData() error { // Load harvest entries if err := gs.loadHarvestEntries(); err != nil { return err } // Load harvest items if err := gs.loadHarvestItems(); err != nil { return err } return nil } func (gs *GroundSpawn) loadHarvestEntries() error { gs.HarvestEntries = make([]*HarvestEntry, 0) if gs.db.GetType() == database.SQLite { return gs.db.ExecTransient(` SELECT groundspawn_id, min_skill_level, min_adventure_level, bonus_table, harvest1, harvest3, harvest5, harvest_imbue, harvest_rare, harvest10, harvest_coin FROM groundspawn_entries WHERE groundspawn_id = ? `, func(stmt *sqlite.Stmt) error { entry := &HarvestEntry{ GroundSpawnID: stmt.ColumnInt32(0), MinSkillLevel: int16(stmt.ColumnInt32(1)), MinAdventureLevel: int16(stmt.ColumnInt32(2)), BonusTable: stmt.ColumnBool(3), Harvest1: float32(stmt.ColumnFloat(4)), Harvest3: float32(stmt.ColumnFloat(5)), Harvest5: float32(stmt.ColumnFloat(6)), HarvestImbue: float32(stmt.ColumnFloat(7)), HarvestRare: float32(stmt.ColumnFloat(8)), Harvest10: float32(stmt.ColumnFloat(9)), HarvestCoin: float32(stmt.ColumnFloat(10)), } gs.HarvestEntries = append(gs.HarvestEntries, entry) return nil }, gs.GroundSpawnID) } else { // MySQL implementation rows, err := gs.db.Query(` SELECT groundspawn_id, min_skill_level, min_adventure_level, bonus_table, harvest1, harvest3, harvest5, harvest_imbue, harvest_rare, harvest10, harvest_coin FROM groundspawn_entries WHERE groundspawn_id = ? `, gs.GroundSpawnID) if err != nil { return err } defer rows.Close() for rows.Next() { entry := &HarvestEntry{} err := rows.Scan(&entry.GroundSpawnID, &entry.MinSkillLevel, &entry.MinAdventureLevel, &entry.BonusTable, &entry.Harvest1, &entry.Harvest3, &entry.Harvest5, &entry.HarvestImbue, &entry.HarvestRare, &entry.Harvest10, &entry.HarvestCoin) if err != nil { return err } gs.HarvestEntries = append(gs.HarvestEntries, entry) } return rows.Err() } } func (gs *GroundSpawn) loadHarvestItems() error { gs.HarvestItems = make([]*HarvestEntryItem, 0) if gs.db.GetType() == database.SQLite { return gs.db.ExecTransient(` SELECT groundspawn_id, item_id, is_rare, grid_id, quantity FROM groundspawn_items WHERE groundspawn_id = ? `, func(stmt *sqlite.Stmt) error { item := &HarvestEntryItem{ GroundSpawnID: stmt.ColumnInt32(0), ItemID: stmt.ColumnInt32(1), IsRare: int8(stmt.ColumnInt32(2)), GridID: stmt.ColumnInt32(3), Quantity: int16(stmt.ColumnInt32(4)), } gs.HarvestItems = append(gs.HarvestItems, item) return nil }, gs.GroundSpawnID) } else { // MySQL implementation rows, err := gs.db.Query(` SELECT groundspawn_id, item_id, is_rare, grid_id, quantity FROM groundspawn_items WHERE groundspawn_id = ? `, gs.GroundSpawnID) if err != nil { return err } defer rows.Close() for rows.Next() { item := &HarvestEntryItem{} err := rows.Scan(&item.GroundSpawnID, &item.ItemID, &item.IsRare, &item.GridID, &item.Quantity) if err != nil { return err } gs.HarvestItems = append(gs.HarvestItems, item) } return rows.Err() } }