diff --git a/internal/towns/builder.go b/internal/towns/builder.go new file mode 100644 index 0000000..8355ef5 --- /dev/null +++ b/internal/towns/builder.go @@ -0,0 +1,115 @@ +package towns + +import ( + "fmt" + + "dk/internal/database" + + "zombiezen.com/go/sqlite" +) + +// Builder provides a fluent interface for creating towns +type Builder struct { + town *Town + db *database.DB +} + +// NewBuilder creates a new town builder +func NewBuilder(db *database.DB) *Builder { + return &Builder{ + town: &Town{ + db: db, + }, + db: db, + } +} + +// WithName sets the town name +func (b *Builder) WithName(name string) *Builder { + b.town.Name = name + return b +} + +// WithX sets the X coordinate +func (b *Builder) WithX(x int) *Builder { + b.town.X = x + return b +} + +// WithY sets the Y coordinate +func (b *Builder) WithY(y int) *Builder { + b.town.Y = y + return b +} + +// WithCoordinates sets both X and Y coordinates +func (b *Builder) WithCoordinates(x, y int) *Builder { + b.town.X = x + b.town.Y = y + return b +} + +// WithInnCost sets the inn cost +func (b *Builder) WithInnCost(cost int) *Builder { + b.town.InnCost = cost + return b +} + +// WithMapCost sets the map cost +func (b *Builder) WithMapCost(cost int) *Builder { + b.town.MapCost = cost + return b +} + +// WithTPCost sets the teleport cost +func (b *Builder) WithTPCost(cost int) *Builder { + b.town.TPCost = cost + return b +} + +// WithShopList sets the shop list as a comma-separated string +func (b *Builder) WithShopList(shopList string) *Builder { + b.town.ShopList = shopList + return b +} + +// WithShopItems sets the shop items from a slice of item IDs +func (b *Builder) WithShopItems(items []string) *Builder { + b.town.SetShopItems(items) + return b +} + +// Create saves the town to the database and returns the created town with ID +func (b *Builder) Create() (*Town, error) { + // Use a transaction to ensure we can get the ID + var town *Town + err := b.db.Transaction(func(tx *database.Tx) error { + query := `INSERT INTO towns (name, x, y, inn_cost, map_cost, tp_cost, shop_list) + VALUES (?, ?, ?, ?, ?, ?, ?)` + + if err := tx.Exec(query, b.town.Name, b.town.X, b.town.Y, + b.town.InnCost, b.town.MapCost, b.town.TPCost, b.town.ShopList); err != nil { + return fmt.Errorf("failed to insert town: %w", err) + } + + // Get the last insert ID + var id int + err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error { + id = stmt.ColumnInt(0) + return nil + }) + if err != nil { + return fmt.Errorf("failed to get insert ID: %w", err) + } + + b.town.ID = id + town = b.town + return nil + }) + + if err != nil { + return nil, err + } + + return town, nil +} \ No newline at end of file diff --git a/internal/towns/doc.go b/internal/towns/doc.go new file mode 100644 index 0000000..8df94a1 --- /dev/null +++ b/internal/towns/doc.go @@ -0,0 +1,295 @@ +/* +Package towns is the active record implementation for towns in the game. + +# Basic Usage + +To retrieve a town by ID: + + town, err := towns.Find(db, 1) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Found town: %s at (%d,%d)\n", town.Name, town.X, town.Y) + +To get all towns: + + allTowns, err := towns.All(db) + if err != nil { + log.Fatal(err) + } + for _, town := range allTowns { + fmt.Printf("Town: %s\n", town.Name) + } + +To find a town by name: + + midworld, err := towns.ByName(db, "Midworld") + if err != nil { + log.Fatal(err) + } + +To filter towns by affordability: + + cheapInns, err := towns.ByMaxInnCost(db, 25) + if err != nil { + log.Fatal(err) + } + + affordableTP, err := towns.ByMaxTPCost(db, 50) + if err != nil { + log.Fatal(err) + } + +To find nearby towns: + + nearbyTowns, err := towns.ByDistance(db, playerX, playerY, 100) + if err != nil { + log.Fatal(err) + } + +# Creating Towns with Builder Pattern + +The package provides a fluent builder interface for creating new towns: + + town, err := towns.NewBuilder(db). + WithName("New Settlement"). + WithCoordinates(150, -75). + WithInnCost(35). + WithMapCost(125). + WithTPCost(40). + WithShopItems([]string{"1", "5", "10", "20"}). + Create() + + if err != nil { + log.Fatal(err) + } + fmt.Printf("Created town with ID: %d\n", town.ID) + +# Updating Towns + +Towns can be modified and saved back to the database: + + town, _ := towns.Find(db, 1) + town.Name = "Enhanced Midworld" + town.InnCost = 8 + town.MapCost = 10 + + err := town.Save() + if err != nil { + log.Fatal(err) + } + +# Deleting Towns + +Towns can be removed from the database: + + town, _ := towns.Find(db, 1) + err := town.Delete() + if err != nil { + log.Fatal(err) + } + +# Database Schema + +The towns table has the following structure: + + CREATE TABLE towns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + x INTEGER NOT NULL DEFAULT 0, + y INTEGER NOT NULL DEFAULT 0, + inn_cost INTEGER NOT NULL DEFAULT 0, + map_cost INTEGER NOT NULL DEFAULT 0, + tp_cost INTEGER NOT NULL DEFAULT 0, + shop_list TEXT NOT NULL DEFAULT '' + ) + +Where: + - id: Unique identifier + - name: Display name of the town + - x, y: World coordinates for the town location + - inn_cost: Cost in gold to rest at the inn + - map_cost: Cost in gold to buy the world map from this town + - tp_cost: Cost in gold to teleport to this town from elsewhere + - shop_list: Comma-separated list of item IDs sold in the town shop + +# Town Economics + +## Inn System + +Towns provide rest services through inns: + + if town.CanAffordInn(playerGold) { + fmt.Printf("You can rest at %s for %d gold\n", town.Name, town.InnCost) + } + +Inn costs typically increase with town size and distance from starting area. + +## Map Purchase + +Maps can be bought from certain towns: + + if town.MapCost > 0 && town.CanAffordMap(playerGold) { + fmt.Printf("Map available for %d gold at %s\n", town.MapCost, town.Name) + } + +Not all towns sell maps (cost = 0 means unavailable). + +## Teleportation Network + +Towns with teleport access allow fast travel: + + if town.TPCost > 0 && town.CanAffordTeleport(playerGold) { + fmt.Printf("Can teleport to %s for %d gold\n", town.Name, town.TPCost) + } + +Starting town (Midworld) typically has no teleport cost. + +# Shop System + +## Item Management + +Towns have shops that sell specific items: + + shopItems := town.GetShopItems() + fmt.Printf("%s sells %d different items\n", town.Name, len(shopItems)) + + if town.HasShopItem("5") { + fmt.Println("This town sells item ID 5") + } + +## Shop Inventory + +Shop inventories are stored as comma-separated item ID lists: + + // Current inventory + items := town.GetShopItems() // Returns []string{"1", "2", "3"} + + // Update inventory + newItems := []string{"10", "15", "20", "25"} + town.SetShopItems(newItems) + town.Save() // Persist changes + +# Geographic Queries + +## Coordinate System + +Towns exist on a 2D coordinate plane: + + fmt.Printf("%s is located at (%d, %d)\n", town.Name, town.X, town.Y) + + // Calculate distance between towns + distance := town1.DistanceFrom(town2.X, town2.Y) + +## Proximity Search + +Find towns within a certain distance: + + // Get all towns within 50 units of player position + nearbyTowns, err := towns.ByDistance(db, playerX, playerY, 50) + + // Results are ordered by distance (closest first) + if len(nearbyTowns) > 0 { + closest := nearbyTowns[0] + fmt.Printf("Closest town: %s\n", closest.Name) + } + +## Starting Location + +The starting town is identifiable: + + if town.IsStartingTown() { + fmt.Println("This is where new players begin") + } + +Starting towns are typically at coordinates (0, 0). + +# Cost-Based Queries + +## Budget-Friendly Services + +Find towns within budget constraints: + + // Towns with affordable inns + cheapInns, err := towns.ByMaxInnCost(db, playerGold) + + // Towns with affordable teleportation + affordableTP, err := towns.ByMaxTPCost(db, playerGold) + +Results are ordered by cost (cheapest first). + +## Service Availability + +Check what services a town offers: + + fmt.Printf("Services at %s:\n", town.Name) + if town.InnCost > 0 { + fmt.Printf("- Inn: %d gold\n", town.InnCost) + } + if town.MapCost > 0 { + fmt.Printf("- Map: %d gold\n", town.MapCost) + } + if town.TPCost > 0 { + fmt.Printf("- Teleport destination: %d gold\n", town.TPCost) + } + +# Game Progression + +## Town Hierarchy + +Towns typically follow a progression pattern: + +1. **Starting Towns** (Midworld): Free teleport, basic services, low costs +2. **Early Game Towns** (Roma, Bris): Moderate costs, expanded shops +3. **Mid Game Towns** (Kalle, Narcissa): Higher costs, specialized items +4. **Late Game Towns** (Hambry, Gilead, Endworld): Premium services, rare items + +## Economic Scaling + +Service costs often scale with game progression: + + // Example progression analysis + towns, _ := towns.All(db) + for _, town := range towns { + if town.TPCost > 100 { + fmt.Printf("%s is a late-game destination\n", town.Name) + } + } + +# World Map Integration + +## Navigation + +Towns serve as waypoints for world navigation: + + // Find path between towns + startTown, _ := towns.ByName(db, "Midworld") + endTown, _ := towns.ByName(db, "Endworld") + + distance := startTown.DistanceFrom(endTown.X, endTown.Y) + fmt.Printf("Distance from %s to %s: %.0f units\n", + startTown.Name, endTown.Name, distance) + +## Strategic Planning + +Use town data for strategic decisions: + + // Find cheapest inn route + route := []string{"Midworld", "Roma", "Bris"} + totalInnCost := 0 + + for _, townName := range route { + town, _ := towns.ByName(db, townName) + totalInnCost += town.InnCost + } + + fmt.Printf("Route inn cost: %d gold\n", totalInnCost) + +# Error Handling + +All functions return appropriate errors for common failure cases: + - Town not found (Find/ByName returns error for non-existent towns) + - Database connection issues + - Invalid operations (e.g., saving/deleting towns without IDs) +*/ +package towns \ No newline at end of file diff --git a/internal/towns/towns.go b/internal/towns/towns.go new file mode 100644 index 0000000..0217cec --- /dev/null +++ b/internal/towns/towns.go @@ -0,0 +1,268 @@ +package towns + +import ( + "fmt" + "strings" + + "dk/internal/database" + + "zombiezen.com/go/sqlite" +) + +// Town represents a town in the database +type Town struct { + ID int `json:"id"` + Name string `json:"name"` + X int `json:"x"` + Y int `json:"y"` + InnCost int `json:"inn_cost"` + MapCost int `json:"map_cost"` + TPCost int `json:"tp_cost"` + ShopList string `json:"shop_list"` + + db *database.DB +} + +// Find retrieves a town by ID +func Find(db *database.DB, id int) (*Town, error) { + town := &Town{db: db} + + query := "SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list FROM towns WHERE id = ?" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + town.ID = stmt.ColumnInt(0) + town.Name = stmt.ColumnText(1) + town.X = stmt.ColumnInt(2) + town.Y = stmt.ColumnInt(3) + town.InnCost = stmt.ColumnInt(4) + town.MapCost = stmt.ColumnInt(5) + town.TPCost = stmt.ColumnInt(6) + town.ShopList = stmt.ColumnText(7) + return nil + }, id) + + if err != nil { + return nil, fmt.Errorf("failed to find town: %w", err) + } + + if town.ID == 0 { + return nil, fmt.Errorf("town with ID %d not found", id) + } + + return town, nil +} + +// All retrieves all towns +func All(db *database.DB) ([]*Town, error) { + var towns []*Town + + query := "SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list FROM towns ORDER BY id" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + town := &Town{ + ID: stmt.ColumnInt(0), + Name: stmt.ColumnText(1), + X: stmt.ColumnInt(2), + Y: stmt.ColumnInt(3), + InnCost: stmt.ColumnInt(4), + MapCost: stmt.ColumnInt(5), + TPCost: stmt.ColumnInt(6), + ShopList: stmt.ColumnText(7), + db: db, + } + towns = append(towns, town) + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve all towns: %w", err) + } + + return towns, nil +} + +// ByName retrieves a town by name (case-insensitive) +func ByName(db *database.DB, name string) (*Town, error) { + town := &Town{db: db} + + query := "SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list FROM towns WHERE LOWER(name) = LOWER(?) LIMIT 1" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + town.ID = stmt.ColumnInt(0) + town.Name = stmt.ColumnText(1) + town.X = stmt.ColumnInt(2) + town.Y = stmt.ColumnInt(3) + town.InnCost = stmt.ColumnInt(4) + town.MapCost = stmt.ColumnInt(5) + town.TPCost = stmt.ColumnInt(6) + town.ShopList = stmt.ColumnText(7) + return nil + }, name) + + if err != nil { + return nil, fmt.Errorf("failed to find town by name: %w", err) + } + + if town.ID == 0 { + return nil, fmt.Errorf("town with name '%s' not found", name) + } + + return town, nil +} + +// ByMaxInnCost retrieves towns with inn cost at most the specified amount +func ByMaxInnCost(db *database.DB, maxCost int) ([]*Town, error) { + var towns []*Town + + query := "SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list FROM towns WHERE inn_cost <= ? ORDER BY inn_cost, id" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + town := &Town{ + ID: stmt.ColumnInt(0), + Name: stmt.ColumnText(1), + X: stmt.ColumnInt(2), + Y: stmt.ColumnInt(3), + InnCost: stmt.ColumnInt(4), + MapCost: stmt.ColumnInt(5), + TPCost: stmt.ColumnInt(6), + ShopList: stmt.ColumnText(7), + db: db, + } + towns = append(towns, town) + return nil + }, maxCost) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve towns by max inn cost: %w", err) + } + + return towns, nil +} + +// ByMaxTPCost retrieves towns with teleport cost at most the specified amount +func ByMaxTPCost(db *database.DB, maxCost int) ([]*Town, error) { + var towns []*Town + + query := "SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list FROM towns WHERE tp_cost <= ? ORDER BY tp_cost, id" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + town := &Town{ + ID: stmt.ColumnInt(0), + Name: stmt.ColumnText(1), + X: stmt.ColumnInt(2), + Y: stmt.ColumnInt(3), + InnCost: stmt.ColumnInt(4), + MapCost: stmt.ColumnInt(5), + TPCost: stmt.ColumnInt(6), + ShopList: stmt.ColumnText(7), + db: db, + } + towns = append(towns, town) + return nil + }, maxCost) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve towns by max TP cost: %w", err) + } + + return towns, nil +} + +// ByDistance retrieves towns within a certain distance from a point +func ByDistance(db *database.DB, fromX, fromY, maxDistance int) ([]*Town, error) { + var towns []*Town + + query := `SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list + FROM towns + WHERE ((x - ?) * (x - ?) + (y - ?) * (y - ?)) <= ? + ORDER BY ((x - ?) * (x - ?) + (y - ?) * (y - ?)), id` + + maxDistance2 := maxDistance * maxDistance + err := db.Query(query, func(stmt *sqlite.Stmt) error { + town := &Town{ + ID: stmt.ColumnInt(0), + Name: stmt.ColumnText(1), + X: stmt.ColumnInt(2), + Y: stmt.ColumnInt(3), + InnCost: stmt.ColumnInt(4), + MapCost: stmt.ColumnInt(5), + TPCost: stmt.ColumnInt(6), + ShopList: stmt.ColumnText(7), + db: db, + } + towns = append(towns, town) + return nil + }, fromX, fromX, fromY, fromY, maxDistance2, fromX, fromX, fromY, fromY) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve towns by distance: %w", err) + } + + return towns, nil +} + +// Save updates an existing town in the database +func (t *Town) Save() error { + if t.ID == 0 { + return fmt.Errorf("cannot save town without ID") + } + + query := `UPDATE towns SET name = ?, x = ?, y = ?, inn_cost = ?, map_cost = ?, tp_cost = ?, shop_list = ? WHERE id = ?` + return t.db.Exec(query, t.Name, t.X, t.Y, t.InnCost, t.MapCost, t.TPCost, t.ShopList, t.ID) +} + +// Delete removes the town from the database +func (t *Town) Delete() error { + if t.ID == 0 { + return fmt.Errorf("cannot delete town without ID") + } + + query := "DELETE FROM towns WHERE id = ?" + return t.db.Exec(query, t.ID) +} + +// GetShopItems returns the shop items as a slice of item IDs +func (t *Town) GetShopItems() []string { + if t.ShopList == "" { + return []string{} + } + return strings.Split(t.ShopList, ",") +} + +// SetShopItems sets the shop items from a slice of item IDs +func (t *Town) SetShopItems(items []string) { + t.ShopList = strings.Join(items, ",") +} + +// HasShopItem checks if the town's shop sells a specific item ID +func (t *Town) HasShopItem(itemID string) bool { + items := t.GetShopItems() + for _, item := range items { + if strings.TrimSpace(item) == itemID { + return true + } + } + return false +} + +// DistanceFrom calculates the distance from this town to given coordinates +func (t *Town) DistanceFrom(x, y int) float64 { + dx := float64(t.X - x) + dy := float64(t.Y - y) + return dx*dx + dy*dy // Return squared distance for performance +} + +// IsStartingTown returns true if this is the starting town (Midworld) +func (t *Town) IsStartingTown() bool { + return t.X == 0 && t.Y == 0 +} + +// CanAffordInn returns true if the player can afford the inn +func (t *Town) CanAffordInn(gold int) bool { + return gold >= t.InnCost +} + +// CanAffordMap returns true if the player can afford to buy the map +func (t *Town) CanAffordMap(gold int) bool { + return gold >= t.MapCost +} + +// CanAffordTeleport returns true if the player can afford to teleport here +func (t *Town) CanAffordTeleport(gold int) bool { + return gold >= t.TPCost +} \ No newline at end of file diff --git a/internal/towns/towns_test.go b/internal/towns/towns_test.go new file mode 100644 index 0000000..1bbed7c --- /dev/null +++ b/internal/towns/towns_test.go @@ -0,0 +1,440 @@ +package towns + +import ( + "os" + "testing" + + "dk/internal/database" +) + +func setupTestDB(t *testing.T) *database.DB { + testDB := "test_towns.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 towns table + createTable := `CREATE TABLE towns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + x INTEGER NOT NULL DEFAULT 0, + y INTEGER NOT NULL DEFAULT 0, + inn_cost INTEGER NOT NULL DEFAULT 0, + map_cost INTEGER NOT NULL DEFAULT 0, + tp_cost INTEGER NOT NULL DEFAULT 0, + shop_list TEXT NOT NULL DEFAULT '' + )` + + if err := db.Exec(createTable); err != nil { + t.Fatalf("Failed to create towns table: %v", err) + } + + // Insert test data + testTowns := `INSERT INTO towns (name, x, y, inn_cost, map_cost, tp_cost, shop_list) VALUES + ('Midworld', 0, 0, 5, 0, 0, '1,2,3,17,18,19'), + ('Roma', 30, 30, 10, 25, 5, '2,3,4,18,19,29'), + ('Bris', 70, -70, 25, 50, 15, '2,3,4,5,18,19,20'), + ('Kalle', -100, 100, 40, 100, 30, '5,6,8,10,12,21,22,23'), + ('Endworld', -250, -250, 125, 9000, 160, '16,27,33')` + + if err := db.Exec(testTowns); err != nil { + t.Fatalf("Failed to insert test towns: %v", err) + } + + return db +} + +func TestFind(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test finding existing town + town, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find town: %v", err) + } + + if town.ID != 1 { + t.Errorf("Expected ID 1, got %d", town.ID) + } + if town.Name != "Midworld" { + t.Errorf("Expected name 'Midworld', got '%s'", town.Name) + } + if town.X != 0 { + t.Errorf("Expected X 0, got %d", town.X) + } + if town.Y != 0 { + t.Errorf("Expected Y 0, got %d", town.Y) + } + if town.InnCost != 5 { + t.Errorf("Expected inn_cost 5, got %d", town.InnCost) + } + if town.MapCost != 0 { + t.Errorf("Expected map_cost 0, got %d", town.MapCost) + } + if town.TPCost != 0 { + t.Errorf("Expected tp_cost 0, got %d", town.TPCost) + } + if town.ShopList != "1,2,3,17,18,19" { + t.Errorf("Expected shop_list '1,2,3,17,18,19', got '%s'", town.ShopList) + } + + // Test finding non-existent town + _, err = Find(db, 999) + if err == nil { + t.Error("Expected error when finding non-existent town") + } +} + +func TestAll(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + towns, err := All(db) + if err != nil { + t.Fatalf("Failed to get all towns: %v", err) + } + + if len(towns) != 5 { + t.Errorf("Expected 5 towns, got %d", len(towns)) + } + + // Check ordering (by ID) + if towns[0].Name != "Midworld" { + t.Errorf("Expected first town to be 'Midworld', got '%s'", towns[0].Name) + } +} + +func TestByName(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test finding existing town by name + town, err := ByName(db, "Midworld") + if err != nil { + t.Fatalf("Failed to find town by name: %v", err) + } + + if town.Name != "Midworld" { + t.Errorf("Expected name 'Midworld', got '%s'", town.Name) + } + if town.X != 0 || town.Y != 0 { + t.Errorf("Expected coordinates (0,0), got (%d,%d)", town.X, town.Y) + } + + // Test case insensitivity + townLower, err := ByName(db, "midworld") + if err != nil { + t.Fatalf("Failed to find town by lowercase name: %v", err) + } + + if townLower.ID != town.ID { + t.Error("Case insensitive search should return same town") + } + + // Test non-existent town + _, err = ByName(db, "Atlantis") + if err == nil { + t.Error("Expected error when finding non-existent town by name") + } +} + +func TestByMaxInnCost(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test towns with inn cost <= 10 + cheapInns, err := ByMaxInnCost(db, 10) + if err != nil { + t.Fatalf("Failed to get towns by max inn cost: %v", err) + } + + expectedCount := 2 // Midworld(5) and Roma(10) + if len(cheapInns) != expectedCount { + t.Errorf("Expected %d towns with inn cost <= 10, got %d", expectedCount, len(cheapInns)) + } + + // Verify all towns have inn cost <= 10 + for _, town := range cheapInns { + if town.InnCost > 10 { + t.Errorf("Town %s has inn cost %d, expected <= 10", town.Name, town.InnCost) + } + } + + // Verify ordering (by inn cost, then ID) + if cheapInns[0].InnCost > cheapInns[1].InnCost { + t.Error("Expected towns to be ordered by inn cost") + } +} + +func TestByMaxTPCost(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test towns with TP cost <= 15 + cheapTP, err := ByMaxTPCost(db, 15) + if err != nil { + t.Fatalf("Failed to get towns by max TP cost: %v", err) + } + + expectedCount := 3 // Midworld(0), Roma(5), Bris(15) + if len(cheapTP) != expectedCount { + t.Errorf("Expected %d towns with TP cost <= 15, got %d", expectedCount, len(cheapTP)) + } + + // Verify all towns have TP cost <= 15 + for _, town := range cheapTP { + if town.TPCost > 15 { + t.Errorf("Town %s has TP cost %d, expected <= 15", town.Name, town.TPCost) + } + } +} + +func TestByDistance(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test towns within distance 50 from origin (0,0) + nearbyTowns, err := ByDistance(db, 0, 0, 50) + if err != nil { + t.Fatalf("Failed to get towns by distance: %v", err) + } + + // Midworld (0,0) distance=0, Roma (30,30) distance=sqrt(1800)≈42.4 + expectedCount := 2 + if len(nearbyTowns) != expectedCount { + t.Errorf("Expected %d towns within distance 50, got %d", expectedCount, len(nearbyTowns)) + } + + // Verify distances are within limit + for _, town := range nearbyTowns { + distance := town.DistanceFrom(0, 0) + if distance > 50*50 { // Using squared distance + t.Errorf("Town %s distance %.2f is beyond limit", town.Name, distance) + } + } + + // Verify ordering (by distance) + if len(nearbyTowns) >= 2 { + dist1 := nearbyTowns[0].DistanceFrom(0, 0) + dist2 := nearbyTowns[1].DistanceFrom(0, 0) + if dist1 > dist2 { + t.Error("Expected towns to be ordered by distance") + } + } +} + +func TestBuilder(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Create new town using builder + town, err := NewBuilder(db). + WithName("Test City"). + WithCoordinates(100, -50). + WithInnCost(20). + WithMapCost(75). + WithTPCost(25). + WithShopItems([]string{"1", "2", "10", "15"}). + Create() + + if err != nil { + t.Fatalf("Failed to create town with builder: %v", err) + } + + if town.ID == 0 { + t.Error("Expected non-zero ID after creation") + } + if town.Name != "Test City" { + t.Errorf("Expected name 'Test City', got '%s'", town.Name) + } + if town.X != 100 { + t.Errorf("Expected X 100, got %d", town.X) + } + if town.Y != -50 { + t.Errorf("Expected Y -50, got %d", town.Y) + } + if town.InnCost != 20 { + t.Errorf("Expected inn cost 20, got %d", town.InnCost) + } + if town.MapCost != 75 { + t.Errorf("Expected map cost 75, got %d", town.MapCost) + } + if town.TPCost != 25 { + t.Errorf("Expected TP cost 25, got %d", town.TPCost) + } + if town.ShopList != "1,2,10,15" { + t.Errorf("Expected shop list '1,2,10,15', got '%s'", town.ShopList) + } + + // Verify it was saved to database + foundTown, err := Find(db, town.ID) + if err != nil { + t.Fatalf("Failed to find created town: %v", err) + } + + if foundTown.Name != "Test City" { + t.Errorf("Created town not found in database") + } +} + +func TestSave(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + town, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find town: %v", err) + } + + // Modify town + town.Name = "Updated Midworld" + town.X = 5 + town.Y = -5 + town.InnCost = 8 + + // Save changes + err = town.Save() + if err != nil { + t.Fatalf("Failed to save town: %v", err) + } + + // Verify changes were saved + updatedTown, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find updated town: %v", err) + } + + if updatedTown.Name != "Updated Midworld" { + t.Errorf("Expected updated name 'Updated Midworld', got '%s'", updatedTown.Name) + } + if updatedTown.X != 5 { + t.Errorf("Expected updated X 5, got %d", updatedTown.X) + } + if updatedTown.Y != -5 { + t.Errorf("Expected updated Y -5, got %d", updatedTown.Y) + } + if updatedTown.InnCost != 8 { + t.Errorf("Expected updated inn cost 8, got %d", updatedTown.InnCost) + } +} + +func TestDelete(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + town, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find town: %v", err) + } + + // Delete town + err = town.Delete() + if err != nil { + t.Fatalf("Failed to delete town: %v", err) + } + + // Verify town was deleted + _, err = Find(db, 1) + if err == nil { + t.Error("Expected error when finding deleted town") + } +} + +func TestShopItemMethods(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + town, _ := Find(db, 1) // Midworld with shop_list "1,2,3,17,18,19" + + // Test GetShopItems + items := town.GetShopItems() + expectedItems := []string{"1", "2", "3", "17", "18", "19"} + if len(items) != len(expectedItems) { + t.Errorf("Expected %d shop items, got %d", len(expectedItems), len(items)) + } + + for i, expected := range expectedItems { + if i < len(items) && items[i] != expected { + t.Errorf("Expected item %s at position %d, got %s", expected, i, items[i]) + } + } + + // Test HasShopItem + if !town.HasShopItem("1") { + t.Error("Expected town to have shop item '1'") + } + if !town.HasShopItem("19") { + t.Error("Expected town to have shop item '19'") + } + if town.HasShopItem("99") { + t.Error("Expected town not to have shop item '99'") + } + + // Test SetShopItems + newItems := []string{"5", "10", "15"} + town.SetShopItems(newItems) + if town.ShopList != "5,10,15" { + t.Errorf("Expected shop list '5,10,15', got '%s'", town.ShopList) + } + + // Test with empty shop list + emptyTown, _ := Find(db, 5) // Create a town with empty shop list + emptyTown.ShopList = "" + emptyItems := emptyTown.GetShopItems() + if len(emptyItems) != 0 { + t.Errorf("Expected 0 items for empty shop list, got %d", len(emptyItems)) + } +} + +func TestUtilityMethods(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + midworld, _ := Find(db, 1) + roma, _ := Find(db, 2) + + // Test DistanceFrom + distance := roma.DistanceFrom(0, 0) // Roma is at (30,30) + expectedDistance := float64(30*30 + 30*30) // 1800 + if distance != expectedDistance { + t.Errorf("Expected distance %.2f, got %.2f", expectedDistance, distance) + } + + // Test IsStartingTown + if !midworld.IsStartingTown() { + t.Error("Expected Midworld to be starting town") + } + if roma.IsStartingTown() { + t.Error("Expected Roma not to be starting town") + } + + // Test CanAffordInn + if !midworld.CanAffordInn(10) { + t.Error("Expected to afford Midworld inn with 10 gold (cost 5)") + } + if midworld.CanAffordInn(3) { + t.Error("Expected not to afford Midworld inn with 3 gold (cost 5)") + } + + // Test CanAffordMap + if !roma.CanAffordMap(30) { + t.Error("Expected to afford Roma map with 30 gold (cost 25)") + } + if roma.CanAffordMap(20) { + t.Error("Expected not to afford Roma map with 20 gold (cost 25)") + } + + // Test CanAffordTeleport + if !roma.CanAffordTeleport(10) { + t.Error("Expected to afford Roma teleport with 10 gold (cost 5)") + } + if roma.CanAffordTeleport(3) { + t.Error("Expected not to afford Roma teleport with 3 gold (cost 5)") + } +} \ No newline at end of file