create drops package

This commit is contained in:
Sky Johnson 2025-08-08 23:14:47 -05:00
parent 0210e7dd28
commit ace43e1053
4 changed files with 690 additions and 34 deletions

147
internal/drops/doc.go Normal file
View File

@ -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

240
internal/drops/drops.go Normal file
View File

@ -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"
}
}

View File

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

View File

@ -78,8 +78,7 @@ func createTables(db *database.DB) error {
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
level INTEGER NOT NULL DEFAULT 0, level INTEGER NOT NULL DEFAULT 0,
type INTEGER NOT NULL DEFAULT 0, type INTEGER NOT NULL DEFAULT 0,
att1 TEXT NOT NULL DEFAULT '', att TEXT NOT NULL DEFAULT ''
att2 TEXT NOT NULL DEFAULT ''
)`}, )`},
{"forum", `CREATE TABLE forum ( {"forum", `CREATE TABLE forum (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -204,38 +203,38 @@ func populateData(db *database.DB) error {
fmt.Println("✓ control table populated") fmt.Println("✓ control table populated")
dropsSQL := `INSERT INTO drops VALUES dropsSQL := `INSERT INTO drops VALUES
(1, 'Life Pebble', 1, 1, 'maxhp,10', ''), (1, 'Life Pebble', 1, 1, 'maxhp,10'),
(2, 'Life Stone', 10, 1, 'maxhp,25', ''), (2, 'Life Stone', 10, 1, 'maxhp,25'),
(3, 'Life Rock', 25, 1, 'maxhp,50', ''), (3, 'Life Rock', 25, 1, 'maxhp,50'),
(4, 'Magic Pebble', 1, 1, 'maxmp,10', ''), (4, 'Magic Pebble', 1, 1, 'maxmp,10'),
(5, 'Magic Stone', 10, 1, 'maxmp,25', ''), (5, 'Magic Stone', 10, 1, 'maxmp,25'),
(6, 'Magic Rock', 25, 1, 'maxmp,50', ''), (6, 'Magic Rock', 25, 1, 'maxmp,50'),
(7, 'Dragon''s Scale', 10, 1, 'defensepower,25', ''), (7, 'Dragon''s Scale', 10, 1, 'defensepower,25'),
(8, 'Dragon''s Plate', 30, 1, 'defensepower,50', ''), (8, 'Dragon''s Plate', 30, 1, 'defensepower,50'),
(9, 'Dragon''s Claw', 10, 1, 'attackpower,25', ''), (9, 'Dragon''s Claw', 10, 1, 'attackpower,25'),
(10, 'Dragon''s Tooth', 30, 1, 'attackpower,50', ''), (10, 'Dragon''s Tooth', 30, 1, 'attackpower,50'),
(11, 'Dragon''s Tear', 35, 1, 'strength,50', ''), (11, 'Dragon''s Tear', 35, 1, 'strength,50'),
(12, 'Dragon''s Wing', 35, 1, 'dexterity,50', ''), (12, 'Dragon''s Wing', 35, 1, 'dexterity,50'),
(13, 'Demon''s Sin', 35, 1, 'maxhp,-50', 'strength,50'), (13, 'Demon''s Sin', 35, 1, 'maxhp,-50,strength,50'),
(14, 'Demon''s Fall', 35, 1, 'maxmp,-50', 'strength,50'), (14, 'Demon''s Fall', 35, 1, 'maxmp,-50,strength,50'),
(15, 'Demon''s Lie', 45, 1, 'maxhp,-100', 'strength,100'), (15, 'Demon''s Lie', 45, 1, 'maxhp,-100,strength,100'),
(16, 'Demon''s Hate', 45, 1, 'maxmp,-100', 'strength,100'), (16, 'Demon''s Hate', 45, 1, 'maxmp,-100,strength,100'),
(17, 'Angel''s Joy', 25, 1, 'maxhp,25', 'strength,25'), (17, 'Angel''s Joy', 25, 1, 'maxhp,25,strength,25'),
(18, 'Angel''s Rise', 30, 1, 'maxhp,50', 'strength,50'), (18, 'Angel''s Rise', 30, 1, 'maxhp,50,strength,50'),
(19, 'Angel''s Truth', 35, 1, 'maxhp,75', 'strength,75'), (19, 'Angel''s Truth', 35, 1, 'maxhp,75,strength,75'),
(20, 'Angel''s Love', 40, 1, 'maxhp,100', 'strength,100'), (20, 'Angel''s Love', 40, 1, 'maxhp,100,strength,100'),
(21, 'Seraph''s Joy', 25, 1, 'maxmp,25', 'dexterity,25'), (21, 'Seraph''s Joy', 25, 1, 'maxmp,25,dexterity,25'),
(22, 'Seraph''s Rise', 30, 1, 'maxmp,50', 'dexterity,50'), (22, 'Seraph''s Rise', 30, 1, 'maxmp,50,dexterity,50'),
(23, 'Seraph''s Truth', 35, 1, 'maxmp,75', 'dexterity,75'), (23, 'Seraph''s Truth', 35, 1, 'maxmp,75,dexterity,75'),
(24, 'Seraph''s Love', 40, 1, 'maxmp,100', 'dexterity,100'), (24, 'Seraph''s Love', 40, 1, 'maxmp,100,dexterity,100'),
(25, 'Ruby', 50, 1, 'maxhp,150', ''), (25, 'Ruby', 50, 1, 'maxhp,150'),
(26, 'Pearl', 50, 1, 'maxmp,150', ''), (26, 'Pearl', 50, 1, 'maxmp,150'),
(27, 'Emerald', 50, 1, 'strength,150', ''), (27, 'Emerald', 50, 1, 'strength,150'),
(28, 'Topaz', 50, 1, 'dexterity,150', ''), (28, 'Topaz', 50, 1, 'dexterity,150'),
(29, 'Obsidian', 50, 1, 'attackpower,150', ''), (29, 'Obsidian', 50, 1, 'attackpower,150'),
(30, 'Diamond', 50, 1, 'defensepower,150', ''), (30, 'Diamond', 50, 1, 'defensepower,150'),
(31, 'Memory Drop', 5, 1, 'expbonus,10', ''), (31, 'Memory Drop', 5, 1, 'expbonus,10'),
(32, 'Fortune Drop', 5, 1, 'goldbonus,10', '')` (32, 'Fortune Drop', 5, 1, 'goldbonus,10')`
if err := db.Exec(dropsSQL); err != nil { if err := db.Exec(dropsSQL); err != nil {
return fmt.Errorf("failed to populate drops table: %w", err) return fmt.Errorf("failed to populate drops table: %w", err)
} }