create towns package

This commit is contained in:
Sky Johnson 2025-08-08 23:33:16 -05:00
parent 89af7644ba
commit a6b34b7b87
4 changed files with 1118 additions and 0 deletions

115
internal/towns/builder.go Normal file
View File

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

295
internal/towns/doc.go Normal file
View File

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

268
internal/towns/towns.go Normal file
View File

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

View File

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