diff --git a/internal/drops/doc.go b/internal/drops/doc.go new file mode 100644 index 0000000..fefc580 --- /dev/null +++ b/internal/drops/doc.go @@ -0,0 +1,147 @@ +/* +Package drops is the active record implementation for drop items in the game. + +# Basic Usage + +To retrieve a drop by ID: + + drop, err := drops.Find(db, 1) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Found drop: %s (level: %d)\n", drop.Name, drop.Level) + +To get all drops: + + allDrops, err := drops.All(db) + if err != nil { + log.Fatal(err) + } + for _, drop := range allDrops { + fmt.Printf("Drop: %s\n", drop.Name) + } + +To filter drops by level (items available at or below a level): + + availableDrops, err := drops.ByLevel(db, 25) + if err != nil { + log.Fatal(err) + } + +To filter drops by type: + + consumables, err := drops.ByType(db, drops.TypeConsumable) + if err != nil { + log.Fatal(err) + } + +# Creating Drops with Builder Pattern + +The package provides a fluent builder interface for creating new drops: + + drop, err := drops.NewBuilder(db). + WithName("Ruby"). + WithLevel(50). + WithType(drops.TypeConsumable). + WithAtt("maxhp,150"). + Create() + + if err != nil { + log.Fatal(err) + } + fmt.Printf("Created drop with ID: %d\n", drop.ID) + +# Updating Drops + +Drops can be modified and saved back to the database: + + drop, _ := drops.Find(db, 1) + drop.Name = "Enhanced Life Pebble" + drop.Level = 5 + drop.Att = "maxhp,15" + + err := drop.Save() + if err != nil { + log.Fatal(err) + } + +# Deleting Drops + +Drops can be removed from the database: + + drop, _ := drops.Find(db, 1) + err := drop.Delete() + if err != nil { + log.Fatal(err) + } + +# Drop Types + +The package defines drop type constants: + + drops.TypeConsumable = 1 // Consumable items like potions, gems, etc. + +Helper methods are available to check drop types: + + if drop.IsConsumable() { + fmt.Println("This is a consumable item") + } + fmt.Printf("Drop type: %s\n", drop.TypeName()) + +# Database Schema + +The drops table has the following structure: + + CREATE TABLE drops ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT '', + level INTEGER NOT NULL DEFAULT 0, + type INTEGER NOT NULL DEFAULT 0, + att TEXT NOT NULL DEFAULT '' + ) + +Where: + - id: Unique identifier + - name: Display name of the drop + - level: Minimum monster level to find this drop + - type: Drop type (1=consumable) + - att: Comma-separated attributes in "key,value,key,value" format + +# Drop Attributes + +The att field contains attribute bonuses in comma-separated "key,value" pairs: + + "maxhp,10" // +10 max health + "maxmp,25" // +25 max mana + "strength,50" // +50 strength + "defensepower,25" // +25 defense power + "expbonus,10" // +10% experience bonus + "goldbonus,5" // +5% gold bonus + +Many drops have multiple attributes in a single field: + + drop.Att = "maxhp,25,strength,25" // +25 max health AND +25 strength + drop.Att = "maxmp,-50,strength,100" // -50 max mana AND +100 strength + +The attributes are parsed as alternating key-value pairs separated by commas. + +# Level Requirements + +Drops have level requirements that determine when players can use them: + + // Get all drops available from level 10 and above monsters + availableDrops, err := drops.ByLevel(db, 10) + + // This returns drops with level <= 10 + for _, drop := range availableDrops { + fmt.Printf("%s (level %d)\n", drop.Name, drop.Level) + } + +# Error Handling + +All functions return appropriate errors for common failure cases: + - Drop not found (Find returns error for non-existent IDs) + - Database connection issues + - Invalid operations (e.g., saving/deleting drops without IDs) +*/ +package drops diff --git a/internal/drops/drops.go b/internal/drops/drops.go new file mode 100644 index 0000000..666d356 --- /dev/null +++ b/internal/drops/drops.go @@ -0,0 +1,240 @@ +package drops + +import ( + "fmt" + + "dk/internal/database" + + "zombiezen.com/go/sqlite" +) + +// Drop represents a drop item in the database +type Drop struct { + ID int `json:"id"` + Name string `json:"name"` + Level int `json:"level"` + Type int `json:"type"` + Att string `json:"att"` + + db *database.DB +} + +// DropType constants for drop types +const ( + TypeConsumable = 1 +) + +// Find retrieves a drop by ID +func Find(db *database.DB, id int) (*Drop, error) { + drop := &Drop{db: db} + + query := "SELECT id, name, level, type, att FROM drops WHERE id = ?" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + drop.ID = stmt.ColumnInt(0) + drop.Name = stmt.ColumnText(1) + drop.Level = stmt.ColumnInt(2) + drop.Type = stmt.ColumnInt(3) + drop.Att = stmt.ColumnText(4) + return nil + }, id) + + if err != nil { + return nil, fmt.Errorf("failed to find drop: %w", err) + } + + if drop.ID == 0 { + return nil, fmt.Errorf("drop with ID %d not found", id) + } + + return drop, nil +} + +// All retrieves all drops +func All(db *database.DB) ([]*Drop, error) { + var drops []*Drop + + query := "SELECT id, name, level, type, att FROM drops ORDER BY id" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + drop := &Drop{ + ID: stmt.ColumnInt(0), + Name: stmt.ColumnText(1), + Level: stmt.ColumnInt(2), + Type: stmt.ColumnInt(3), + Att: stmt.ColumnText(4), + db: db, + } + drops = append(drops, drop) + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve all drops: %w", err) + } + + return drops, nil +} + +// ByLevel retrieves drops by minimum level requirement +func ByLevel(db *database.DB, minLevel int) ([]*Drop, error) { + var drops []*Drop + + query := "SELECT id, name, level, type, att FROM drops WHERE level <= ? ORDER BY level, id" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + drop := &Drop{ + ID: stmt.ColumnInt(0), + Name: stmt.ColumnText(1), + Level: stmt.ColumnInt(2), + Type: stmt.ColumnInt(3), + Att: stmt.ColumnText(4), + db: db, + } + drops = append(drops, drop) + return nil + }, minLevel) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve drops by level: %w", err) + } + + return drops, nil +} + +// ByType retrieves drops by type +func ByType(db *database.DB, dropType int) ([]*Drop, error) { + var drops []*Drop + + query := "SELECT id, name, level, type, att FROM drops WHERE type = ? ORDER BY level, id" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + drop := &Drop{ + ID: stmt.ColumnInt(0), + Name: stmt.ColumnText(1), + Level: stmt.ColumnInt(2), + Type: stmt.ColumnInt(3), + Att: stmt.ColumnText(4), + db: db, + } + drops = append(drops, drop) + return nil + }, dropType) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve drops by type: %w", err) + } + + return drops, nil +} + +// Builder provides a fluent interface for creating drops +type Builder struct { + drop *Drop + db *database.DB +} + +// NewBuilder creates a new drop builder +func NewBuilder(db *database.DB) *Builder { + return &Builder{ + drop: &Drop{db: db}, + db: db, + } +} + +// WithName sets the drop name +func (b *Builder) WithName(name string) *Builder { + b.drop.Name = name + return b +} + +// WithLevel sets the drop level requirement +func (b *Builder) WithLevel(level int) *Builder { + b.drop.Level = level + return b +} + +// WithType sets the drop type +func (b *Builder) WithType(dropType int) *Builder { + b.drop.Type = dropType + return b +} + +// WithAtt sets the attributes +func (b *Builder) WithAtt(att string) *Builder { + b.drop.Att = att + return b +} + +// Create saves the drop to the database and returns it +func (b *Builder) Create() (*Drop, error) { + // Use a transaction to ensure we can get the ID + var drop *Drop + err := b.db.Transaction(func(tx *database.Tx) error { + query := `INSERT INTO drops (name, level, type, att) + VALUES (?, ?, ?, ?)` + + if err := tx.Exec(query, b.drop.Name, b.drop.Level, b.drop.Type, b.drop.Att); err != nil { + return fmt.Errorf("failed to insert drop: %w", err) + } + + // Get the last inserted ID within the same transaction + var lastID int + err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error { + lastID = stmt.ColumnInt(0) + return nil + }) + if err != nil { + return fmt.Errorf("failed to get last insert ID: %w", err) + } + + // Create the drop with the ID + drop = &Drop{ + ID: lastID, + Name: b.drop.Name, + Level: b.drop.Level, + Type: b.drop.Type, + Att: b.drop.Att, + db: b.db, + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to create drop: %w", err) + } + + return drop, nil +} + +// Save updates an existing drop in the database +func (d *Drop) Save() error { + if d.ID == 0 { + return fmt.Errorf("cannot save drop without ID") + } + + query := `UPDATE drops SET name = ?, level = ?, type = ?, att = ? WHERE id = ?` + return d.db.Exec(query, d.Name, d.Level, d.Type, d.Att, d.ID) +} + +// Delete removes the drop from the database +func (d *Drop) Delete() error { + if d.ID == 0 { + return fmt.Errorf("cannot delete drop without ID") + } + + query := "DELETE FROM drops WHERE id = ?" + return d.db.Exec(query, d.ID) +} + +// IsConsumable returns true if the drop is a consumable item +func (d *Drop) IsConsumable() bool { + return d.Type == TypeConsumable +} + +// TypeName returns the string representation of the drop type +func (d *Drop) TypeName() string { + switch d.Type { + case TypeConsumable: + return "Consumable" + default: + return "Unknown" + } +} diff --git a/internal/drops/drops_test.go b/internal/drops/drops_test.go new file mode 100644 index 0000000..6042707 --- /dev/null +++ b/internal/drops/drops_test.go @@ -0,0 +1,270 @@ +package drops + +import ( + "os" + "testing" + + "dk/internal/database" +) + +func setupTestDB(t *testing.T) *database.DB { + testDB := "test_drops.db" + t.Cleanup(func() { + os.Remove(testDB) + }) + + db, err := database.Open(testDB) + if err != nil { + t.Fatalf("Failed to open test database: %v", err) + } + + // Create drops table + createTable := `CREATE TABLE drops ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT '', + level INTEGER NOT NULL DEFAULT 0, + type INTEGER NOT NULL DEFAULT 0, + att TEXT NOT NULL DEFAULT '' + )` + + if err := db.Exec(createTable); err != nil { + t.Fatalf("Failed to create drops table: %v", err) + } + + // Insert test data + testDrops := `INSERT INTO drops (name, level, type, att) VALUES + ('Life Pebble', 1, 1, 'maxhp,10'), + ('Magic Stone', 10, 1, 'maxmp,25'), + ('Dragon''s Scale', 10, 1, 'defensepower,25'), + ('Angel''s Joy', 25, 1, 'maxhp,25,strength,25')` + + if err := db.Exec(testDrops); err != nil { + t.Fatalf("Failed to insert test drops: %v", err) + } + + return db +} + +func TestFind(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test finding existing drop + drop, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find drop: %v", err) + } + + if drop.ID != 1 { + t.Errorf("Expected ID 1, got %d", drop.ID) + } + if drop.Name != "Life Pebble" { + t.Errorf("Expected name 'Life Pebble', got '%s'", drop.Name) + } + if drop.Level != 1 { + t.Errorf("Expected level 1, got %d", drop.Level) + } + if drop.Type != TypeConsumable { + t.Errorf("Expected type %d, got %d", TypeConsumable, drop.Type) + } + if drop.Att != "maxhp,10" { + t.Errorf("Expected att1 'maxhp,10', got '%s'", drop.Att) + } + + // Test finding non-existent drop + _, err = Find(db, 999) + if err == nil { + t.Error("Expected error when finding non-existent drop") + } +} + +func TestAll(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + drops, err := All(db) + if err != nil { + t.Fatalf("Failed to get all drops: %v", err) + } + + if len(drops) != 4 { + t.Errorf("Expected 4 drops, got %d", len(drops)) + } + + // Check first drop + if drops[0].Name != "Life Pebble" { + t.Errorf("Expected first drop to be 'Life Pebble', got '%s'", drops[0].Name) + } +} + +func TestByLevel(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test drops available at level 10 + drops, err := ByLevel(db, 10) + if err != nil { + t.Fatalf("Failed to get drops by level: %v", err) + } + + if len(drops) != 3 { + t.Errorf("Expected 3 drops at level 10, got %d", len(drops)) + } + + // Verify they are ordered by level + if drops[0].Level != 1 { + t.Errorf("Expected first drop level 1, got %d", drops[0].Level) + } + if drops[1].Level != 10 { + t.Errorf("Expected second drop level 10, got %d", drops[1].Level) + } + + // Test drops available at level 1 + lowLevelDrops, err := ByLevel(db, 1) + if err != nil { + t.Fatalf("Failed to get drops by level 1: %v", err) + } + + if len(lowLevelDrops) != 1 { + t.Errorf("Expected 1 drop at level 1, got %d", len(lowLevelDrops)) + } +} + +func TestByType(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + consumables, err := ByType(db, TypeConsumable) + if err != nil { + t.Fatalf("Failed to get consumable drops: %v", err) + } + + if len(consumables) != 4 { + t.Errorf("Expected 4 consumable drops, got %d", len(consumables)) + } + + // Verify they are ordered by level, then ID + if consumables[0].Level > consumables[1].Level { + t.Error("Expected drops to be ordered by level") + } +} + +func TestBuilder(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Create new drop using builder + drop, err := NewBuilder(db). + WithName("Test Drop"). + WithLevel(15). + WithType(TypeConsumable). + WithAtt("strength,20,dexterity,15"). + Create() + + if err != nil { + t.Fatalf("Failed to create drop with builder: %v", err) + } + + if drop.ID == 0 { + t.Error("Expected non-zero ID after creation") + } + if drop.Name != "Test Drop" { + t.Errorf("Expected name 'Test Drop', got '%s'", drop.Name) + } + if drop.Level != 15 { + t.Errorf("Expected level 15, got %d", drop.Level) + } + if drop.Type != TypeConsumable { + t.Errorf("Expected type %d, got %d", TypeConsumable, drop.Type) + } + if drop.Att != "strength,20,dexterity,15" { + t.Errorf("Expected att 'strength,20,dexterity,15', got '%s'", drop.Att) + } + + // Verify it was saved to database + foundDrop, err := Find(db, drop.ID) + if err != nil { + t.Fatalf("Failed to find created drop: %v", err) + } + + if foundDrop.Name != "Test Drop" { + t.Errorf("Created drop not found in database") + } +} + +func TestSave(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + drop, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find drop: %v", err) + } + + // Modify drop + drop.Name = "Updated Life Pebble" + drop.Level = 5 + drop.Att = "maxhp,15" + + // Save changes + err = drop.Save() + if err != nil { + t.Fatalf("Failed to save drop: %v", err) + } + + // Verify changes were saved + updatedDrop, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find updated drop: %v", err) + } + + if updatedDrop.Name != "Updated Life Pebble" { + t.Errorf("Expected updated name 'Updated Life Pebble', got '%s'", updatedDrop.Name) + } + if updatedDrop.Level != 5 { + t.Errorf("Expected updated level 5, got %d", updatedDrop.Level) + } + if updatedDrop.Att != "maxhp,15" { + t.Errorf("Expected updated att 'maxhp,15', got '%s'", updatedDrop.Att) + } +} + +func TestDelete(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + drop, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find drop: %v", err) + } + + // Delete drop + err = drop.Delete() + if err != nil { + t.Fatalf("Failed to delete drop: %v", err) + } + + // Verify drop was deleted + _, err = Find(db, 1) + if err == nil { + t.Error("Expected error when finding deleted drop") + } +} + +func TestDropMethods(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + pebble, _ := Find(db, 1) + + // Test IsConsumable + if !pebble.IsConsumable() { + t.Error("Expected pebble to be consumable") + } + + // Test TypeName + if pebble.TypeName() != "Consumable" { + t.Errorf("Expected pebble type name 'Consumable', got '%s'", pebble.TypeName()) + } + +} diff --git a/internal/install/install.go b/internal/install/install.go index 189c114..30ec525 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -78,8 +78,7 @@ func createTables(db *database.DB) error { name TEXT NOT NULL DEFAULT '', level INTEGER NOT NULL DEFAULT 0, type INTEGER NOT NULL DEFAULT 0, - att1 TEXT NOT NULL DEFAULT '', - att2 TEXT NOT NULL DEFAULT '' + att TEXT NOT NULL DEFAULT '' )`}, {"forum", `CREATE TABLE forum ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -204,38 +203,38 @@ func populateData(db *database.DB) error { fmt.Println("✓ control table populated") dropsSQL := `INSERT INTO drops VALUES - (1, 'Life Pebble', 1, 1, 'maxhp,10', ''), - (2, 'Life Stone', 10, 1, 'maxhp,25', ''), - (3, 'Life Rock', 25, 1, 'maxhp,50', ''), - (4, 'Magic Pebble', 1, 1, 'maxmp,10', ''), - (5, 'Magic Stone', 10, 1, 'maxmp,25', ''), - (6, 'Magic Rock', 25, 1, 'maxmp,50', ''), - (7, 'Dragon''s Scale', 10, 1, 'defensepower,25', ''), - (8, 'Dragon''s Plate', 30, 1, 'defensepower,50', ''), - (9, 'Dragon''s Claw', 10, 1, 'attackpower,25', ''), - (10, 'Dragon''s Tooth', 30, 1, 'attackpower,50', ''), - (11, 'Dragon''s Tear', 35, 1, 'strength,50', ''), - (12, 'Dragon''s Wing', 35, 1, 'dexterity,50', ''), - (13, 'Demon''s Sin', 35, 1, 'maxhp,-50', 'strength,50'), - (14, 'Demon''s Fall', 35, 1, 'maxmp,-50', 'strength,50'), - (15, 'Demon''s Lie', 45, 1, 'maxhp,-100', 'strength,100'), - (16, 'Demon''s Hate', 45, 1, 'maxmp,-100', 'strength,100'), - (17, 'Angel''s Joy', 25, 1, 'maxhp,25', 'strength,25'), - (18, 'Angel''s Rise', 30, 1, 'maxhp,50', 'strength,50'), - (19, 'Angel''s Truth', 35, 1, 'maxhp,75', 'strength,75'), - (20, 'Angel''s Love', 40, 1, 'maxhp,100', 'strength,100'), - (21, 'Seraph''s Joy', 25, 1, 'maxmp,25', 'dexterity,25'), - (22, 'Seraph''s Rise', 30, 1, 'maxmp,50', 'dexterity,50'), - (23, 'Seraph''s Truth', 35, 1, 'maxmp,75', 'dexterity,75'), - (24, 'Seraph''s Love', 40, 1, 'maxmp,100', 'dexterity,100'), - (25, 'Ruby', 50, 1, 'maxhp,150', ''), - (26, 'Pearl', 50, 1, 'maxmp,150', ''), - (27, 'Emerald', 50, 1, 'strength,150', ''), - (28, 'Topaz', 50, 1, 'dexterity,150', ''), - (29, 'Obsidian', 50, 1, 'attackpower,150', ''), - (30, 'Diamond', 50, 1, 'defensepower,150', ''), - (31, 'Memory Drop', 5, 1, 'expbonus,10', ''), - (32, 'Fortune Drop', 5, 1, 'goldbonus,10', '')` + (1, 'Life Pebble', 1, 1, 'maxhp,10'), + (2, 'Life Stone', 10, 1, 'maxhp,25'), + (3, 'Life Rock', 25, 1, 'maxhp,50'), + (4, 'Magic Pebble', 1, 1, 'maxmp,10'), + (5, 'Magic Stone', 10, 1, 'maxmp,25'), + (6, 'Magic Rock', 25, 1, 'maxmp,50'), + (7, 'Dragon''s Scale', 10, 1, 'defensepower,25'), + (8, 'Dragon''s Plate', 30, 1, 'defensepower,50'), + (9, 'Dragon''s Claw', 10, 1, 'attackpower,25'), + (10, 'Dragon''s Tooth', 30, 1, 'attackpower,50'), + (11, 'Dragon''s Tear', 35, 1, 'strength,50'), + (12, 'Dragon''s Wing', 35, 1, 'dexterity,50'), + (13, 'Demon''s Sin', 35, 1, 'maxhp,-50,strength,50'), + (14, 'Demon''s Fall', 35, 1, 'maxmp,-50,strength,50'), + (15, 'Demon''s Lie', 45, 1, 'maxhp,-100,strength,100'), + (16, 'Demon''s Hate', 45, 1, 'maxmp,-100,strength,100'), + (17, 'Angel''s Joy', 25, 1, 'maxhp,25,strength,25'), + (18, 'Angel''s Rise', 30, 1, 'maxhp,50,strength,50'), + (19, 'Angel''s Truth', 35, 1, 'maxhp,75,strength,75'), + (20, 'Angel''s Love', 40, 1, 'maxhp,100,strength,100'), + (21, 'Seraph''s Joy', 25, 1, 'maxmp,25,dexterity,25'), + (22, 'Seraph''s Rise', 30, 1, 'maxmp,50,dexterity,50'), + (23, 'Seraph''s Truth', 35, 1, 'maxmp,75,dexterity,75'), + (24, 'Seraph''s Love', 40, 1, 'maxmp,100,dexterity,100'), + (25, 'Ruby', 50, 1, 'maxhp,150'), + (26, 'Pearl', 50, 1, 'maxmp,150'), + (27, 'Emerald', 50, 1, 'strength,150'), + (28, 'Topaz', 50, 1, 'dexterity,150'), + (29, 'Obsidian', 50, 1, 'attackpower,150'), + (30, 'Diamond', 50, 1, 'defensepower,150'), + (31, 'Memory Drop', 5, 1, 'expbonus,10'), + (32, 'Fortune Drop', 5, 1, 'goldbonus,10')` if err := db.Exec(dropsSQL); err != nil { return fmt.Errorf("failed to populate drops table: %w", err) }