443 lines
9.9 KiB
Go
443 lines
9.9 KiB
Go
package towns
|
|
|
|
import (
|
|
"dk/internal/store"
|
|
"fmt"
|
|
"math"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"dk/internal/helpers"
|
|
)
|
|
|
|
// Town represents a town in the game
|
|
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"`
|
|
}
|
|
|
|
func (t *Town) Save() error {
|
|
townStore := GetStore()
|
|
townStore.UpdateTown(t)
|
|
return nil
|
|
}
|
|
|
|
func (t *Town) Delete() error {
|
|
townStore := GetStore()
|
|
townStore.RemoveTown(t.ID)
|
|
return nil
|
|
}
|
|
|
|
// Creates a new Town with sensible defaults
|
|
func New() *Town {
|
|
return &Town{
|
|
Name: "",
|
|
X: 0, // Default coordinates
|
|
Y: 0,
|
|
InnCost: 50, // Default inn cost
|
|
MapCost: 100, // Default map cost
|
|
TPCost: 25, // Default teleport cost
|
|
ShopList: "", // No items by default
|
|
}
|
|
}
|
|
|
|
// Validate checks if town has valid values
|
|
func (t *Town) Validate() error {
|
|
if t.Name == "" {
|
|
return fmt.Errorf("town name cannot be empty")
|
|
}
|
|
if t.InnCost < 0 {
|
|
return fmt.Errorf("town InnCost cannot be negative")
|
|
}
|
|
if t.MapCost < 0 {
|
|
return fmt.Errorf("town MapCost cannot be negative")
|
|
}
|
|
if t.TPCost < 0 {
|
|
return fmt.Errorf("town TPCost cannot be negative")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TownStore provides in-memory storage with O(1) lookups and town-specific indices
|
|
type TownStore struct {
|
|
*store.BaseStore[Town] // Embedded generic store
|
|
byName map[string]int // Name (lowercase) -> ID
|
|
byCoords map[string]int // "x,y" -> ID
|
|
byInnCost map[int][]int // InnCost -> []ID
|
|
byTPCost map[int][]int // TPCost -> []ID
|
|
allByID []int // All IDs sorted by ID
|
|
mu sync.RWMutex // Protects indices
|
|
}
|
|
|
|
// Global in-memory store
|
|
var townStore *TownStore
|
|
var storeOnce sync.Once
|
|
|
|
// Initialize the in-memory store
|
|
func initStore() {
|
|
townStore = &TownStore{
|
|
BaseStore: store.NewBaseStore[Town](),
|
|
byName: make(map[string]int),
|
|
byCoords: make(map[string]int),
|
|
byInnCost: make(map[int][]int),
|
|
byTPCost: make(map[int][]int),
|
|
allByID: make([]int, 0),
|
|
}
|
|
}
|
|
|
|
// GetStore returns the global town store
|
|
func GetStore() *TownStore {
|
|
storeOnce.Do(initStore)
|
|
return townStore
|
|
}
|
|
|
|
// AddTown adds a town to the in-memory store and updates all indices
|
|
func (ts *TownStore) AddTown(town *Town) {
|
|
ts.mu.Lock()
|
|
defer ts.mu.Unlock()
|
|
|
|
// Validate town
|
|
if err := town.Validate(); err != nil {
|
|
return
|
|
}
|
|
|
|
// Add to base store
|
|
ts.Add(town.ID, town)
|
|
|
|
// Rebuild indices
|
|
ts.rebuildIndicesUnsafe()
|
|
}
|
|
|
|
// RemoveTown removes a town from the store and updates indices
|
|
func (ts *TownStore) RemoveTown(id int) {
|
|
ts.mu.Lock()
|
|
defer ts.mu.Unlock()
|
|
|
|
// Remove from base store
|
|
ts.Remove(id)
|
|
|
|
// Rebuild indices
|
|
ts.rebuildIndicesUnsafe()
|
|
}
|
|
|
|
// UpdateTown updates a town efficiently
|
|
func (ts *TownStore) UpdateTown(town *Town) {
|
|
ts.mu.Lock()
|
|
defer ts.mu.Unlock()
|
|
|
|
// Validate town
|
|
if err := town.Validate(); err != nil {
|
|
return
|
|
}
|
|
|
|
// Update base store
|
|
ts.Add(town.ID, town)
|
|
|
|
// Rebuild indices
|
|
ts.rebuildIndicesUnsafe()
|
|
}
|
|
|
|
// LoadData loads town data from JSON file, or starts with empty store
|
|
func LoadData(dataPath string) error {
|
|
ts := GetStore()
|
|
|
|
// Load from base store, which handles JSON loading
|
|
if err := ts.BaseStore.LoadData(dataPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Rebuild indices from loaded data
|
|
ts.rebuildIndices()
|
|
return nil
|
|
}
|
|
|
|
// SaveData saves town data to JSON file
|
|
func SaveData(dataPath string) error {
|
|
ts := GetStore()
|
|
return ts.BaseStore.SaveData(dataPath)
|
|
}
|
|
|
|
// coordsKey creates a key for coordinate-based lookup
|
|
func coordsKey(x, y int) string {
|
|
return strconv.Itoa(x) + "," + strconv.Itoa(y)
|
|
}
|
|
|
|
// rebuildIndicesUnsafe rebuilds all indices from base store data (caller must hold lock)
|
|
func (ts *TownStore) rebuildIndicesUnsafe() {
|
|
// Clear indices
|
|
ts.byName = make(map[string]int)
|
|
ts.byCoords = make(map[string]int)
|
|
ts.byInnCost = make(map[int][]int)
|
|
ts.byTPCost = make(map[int][]int)
|
|
ts.allByID = make([]int, 0)
|
|
|
|
// Collect all towns and build indices
|
|
allTowns := ts.GetAll()
|
|
|
|
for id, town := range allTowns {
|
|
// Name index (case-insensitive)
|
|
ts.byName[strings.ToLower(town.Name)] = id
|
|
|
|
// Coordinates index
|
|
ts.byCoords[coordsKey(town.X, town.Y)] = id
|
|
|
|
// Cost indices
|
|
ts.byInnCost[town.InnCost] = append(ts.byInnCost[town.InnCost], id)
|
|
ts.byTPCost[town.TPCost] = append(ts.byTPCost[town.TPCost], id)
|
|
|
|
// All IDs
|
|
ts.allByID = append(ts.allByID, id)
|
|
}
|
|
|
|
// Sort all by ID
|
|
sort.Ints(ts.allByID)
|
|
|
|
// Sort cost indices by ID
|
|
for innCost := range ts.byInnCost {
|
|
sort.Ints(ts.byInnCost[innCost])
|
|
}
|
|
|
|
for tpCost := range ts.byTPCost {
|
|
sort.Ints(ts.byTPCost[tpCost])
|
|
}
|
|
}
|
|
|
|
// rebuildIndices rebuilds all town-specific indices from base store data
|
|
func (ts *TownStore) rebuildIndices() {
|
|
ts.mu.Lock()
|
|
defer ts.mu.Unlock()
|
|
ts.rebuildIndicesUnsafe()
|
|
}
|
|
|
|
// Retrieves a town by ID
|
|
func Find(id int) (*Town, error) {
|
|
ts := GetStore()
|
|
town, exists := ts.GetByID(id)
|
|
if !exists {
|
|
return nil, fmt.Errorf("town with ID %d not found", id)
|
|
}
|
|
return town, nil
|
|
}
|
|
|
|
// Retrieves all towns
|
|
func All() ([]*Town, error) {
|
|
ts := GetStore()
|
|
ts.mu.RLock()
|
|
defer ts.mu.RUnlock()
|
|
|
|
result := make([]*Town, 0, len(ts.allByID))
|
|
for _, id := range ts.allByID {
|
|
if town, exists := ts.GetByID(id); exists {
|
|
result = append(result, town)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Retrieves a town by name (case-insensitive)
|
|
func ByName(name string) (*Town, error) {
|
|
ts := GetStore()
|
|
ts.mu.RLock()
|
|
defer ts.mu.RUnlock()
|
|
|
|
id, exists := ts.byName[strings.ToLower(name)]
|
|
if !exists {
|
|
return nil, fmt.Errorf("town with name '%s' not found", name)
|
|
}
|
|
|
|
town, exists := ts.GetByID(id)
|
|
if !exists {
|
|
return nil, fmt.Errorf("town with name '%s' not found", name)
|
|
}
|
|
|
|
return town, nil
|
|
}
|
|
|
|
// Retrieves towns with inn cost at most the specified amount
|
|
func ByMaxInnCost(maxCost int) ([]*Town, error) {
|
|
ts := GetStore()
|
|
ts.mu.RLock()
|
|
defer ts.mu.RUnlock()
|
|
|
|
var result []*Town
|
|
for cost := 0; cost <= maxCost; cost++ {
|
|
if ids, exists := ts.byInnCost[cost]; exists {
|
|
for _, id := range ids {
|
|
if town, exists := ts.GetByID(id); exists {
|
|
result = append(result, town)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Retrieves towns with teleport cost at most the specified amount
|
|
func ByMaxTPCost(maxCost int) ([]*Town, error) {
|
|
ts := GetStore()
|
|
ts.mu.RLock()
|
|
defer ts.mu.RUnlock()
|
|
|
|
var result []*Town
|
|
for cost := 0; cost <= maxCost; cost++ {
|
|
if ids, exists := ts.byTPCost[cost]; exists {
|
|
for _, id := range ids {
|
|
if town, exists := ts.GetByID(id); exists {
|
|
result = append(result, town)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Retrieves a town by its x, y coordinates
|
|
func ByCoords(x, y int) (*Town, error) {
|
|
ts := GetStore()
|
|
ts.mu.RLock()
|
|
defer ts.mu.RUnlock()
|
|
|
|
id, exists := ts.byCoords[coordsKey(x, y)]
|
|
if !exists {
|
|
return nil, nil // Return nil if not found (like original)
|
|
}
|
|
|
|
town, exists := ts.GetByID(id)
|
|
if !exists {
|
|
return nil, nil
|
|
}
|
|
|
|
return town, nil
|
|
}
|
|
|
|
// ExistsAt checks for a town at the given coordinates, returning true/false
|
|
func ExistsAt(x, y int) bool {
|
|
ts := GetStore()
|
|
ts.mu.RLock()
|
|
defer ts.mu.RUnlock()
|
|
|
|
_, exists := ts.byCoords[coordsKey(x, y)]
|
|
return exists
|
|
}
|
|
|
|
// Retrieves towns within a certain distance from a point
|
|
func ByDistance(fromX, fromY, maxDistance int) ([]*Town, error) {
|
|
ts := GetStore()
|
|
ts.mu.RLock()
|
|
defer ts.mu.RUnlock()
|
|
|
|
var result []*Town
|
|
maxDistance2 := float64(maxDistance * maxDistance)
|
|
|
|
for _, id := range ts.allByID {
|
|
if town, exists := ts.GetByID(id); exists {
|
|
if town.DistanceFromSquared(fromX, fromY) <= maxDistance2 {
|
|
result = append(result, town)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by distance, then by ID
|
|
sort.Slice(result, func(i, j int) bool {
|
|
distI := result[i].DistanceFromSquared(fromX, fromY)
|
|
distJ := result[j].DistanceFromSquared(fromX, fromY)
|
|
if distI == distJ {
|
|
return result[i].ID < result[j].ID
|
|
}
|
|
return distI < distJ
|
|
})
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// Saves a new town to the in-memory store and sets the ID
|
|
func (t *Town) Insert() error {
|
|
ts := GetStore()
|
|
|
|
// Validate before insertion
|
|
if err := t.Validate(); err != nil {
|
|
return fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
// Assign new ID if not set
|
|
if t.ID == 0 {
|
|
t.ID = ts.GetNextID()
|
|
}
|
|
|
|
// Add to store
|
|
ts.AddTown(t)
|
|
return nil
|
|
}
|
|
|
|
// Returns the shop items as a slice of item IDs
|
|
func (t *Town) GetShopItems() []int {
|
|
return helpers.StringToInts(t.ShopList)
|
|
}
|
|
|
|
// Sets the shop items from a slice of item IDs
|
|
func (t *Town) SetShopItems(items []int) {
|
|
t.ShopList = helpers.IntsToString(items)
|
|
}
|
|
|
|
// Checks if the town's shop sells a specific item ID
|
|
func (t *Town) HasShopItem(itemID int) bool {
|
|
return slices.Contains(t.GetShopItems(), itemID)
|
|
}
|
|
|
|
// Calculates the squared distance from this town to given coordinates
|
|
func (t *Town) DistanceFromSquared(x, y int) float64 {
|
|
dx := float64(t.X - x)
|
|
dy := float64(t.Y - y)
|
|
return dx*dx + dy*dy // Return squared distance for performance
|
|
}
|
|
|
|
// Calculates the actual distance from this town to given coordinates
|
|
func (t *Town) DistanceFrom(x, y int) float64 {
|
|
return math.Sqrt(t.DistanceFromSquared(x, y))
|
|
}
|
|
|
|
// Returns true if this is the starting town (Midworld)
|
|
func (t *Town) IsStartingTown() bool {
|
|
return t.X == 0 && t.Y == 0
|
|
}
|
|
|
|
// Returns true if the player can afford the inn
|
|
func (t *Town) CanAffordInn(gold int) bool {
|
|
return gold >= t.InnCost
|
|
}
|
|
|
|
// Returns true if the player can afford to buy the map
|
|
func (t *Town) CanAffordMap(gold int) bool {
|
|
return gold >= t.MapCost
|
|
}
|
|
|
|
// Returns true if the player can afford to teleport here
|
|
func (t *Town) CanAffordTeleport(gold int) bool {
|
|
return gold >= t.TPCost
|
|
}
|
|
|
|
// Returns true if the town has a shop with items
|
|
func (t *Town) HasShop() bool {
|
|
return len(t.GetShopItems()) > 0
|
|
}
|
|
|
|
// Returns the town's coordinates
|
|
func (t *Town) GetPosition() (int, int) {
|
|
return t.X, t.Y
|
|
}
|
|
|
|
// Sets the town's coordinates
|
|
func (t *Town) SetPosition(x, y int) {
|
|
t.X = x
|
|
t.Y = y
|
|
}
|