eq2go/internal/ground_spawn/ground_spawn.go

724 lines
21 KiB
Go

// 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()
}
}