From 412baeb46d50092dbc24db329eeaaa73cb8dd520 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Thu, 21 Aug 2025 18:00:46 -0500 Subject: [PATCH] first pass migrate back to sqlite --- data/fights.json | 142 +++++++++++++ go.mod | 11 ++ go.sum | 23 +++ internal/database/wrapper.go | 351 +++++++++++++++++++++++++++++++++ internal/models/towns/towns.go | 167 ++++++---------- internal/models/users/users.go | 211 +++++++------------- 6 files changed, 652 insertions(+), 253 deletions(-) create mode 100644 internal/database/wrapper.go diff --git a/data/fights.json b/data/fights.json index 5d5dece..e38fef9 100644 --- a/data/fights.json +++ b/data/fights.json @@ -962,5 +962,147 @@ ], "created": 1755789095, "updated": 1755789098 + }, + { + "id": 17, + "user_id": 1, + "monster_id": 7, + "monster_hp": 0, + "monster_max_hp": 12, + "monster_sleep": 0, + "monster_immune": 1, + "uber_damage": 0, + "uber_defense": 0, + "first_strike": false, + "turn": 6, + "ran_away": false, + "victory": true, + "won": true, + "reward_gold": 3, + "reward_exp": 9, + "actions": [ + { + "t": 1, + "d": 2 + }, + { + "t": 8, + "d": 1, + "n": "Shade" + }, + { + "t": 1, + "d": 2 + }, + { + "t": 8, + "d": 1, + "n": "Shade" + }, + { + "t": 1, + "d": 2 + }, + { + "t": 8, + "d": 1, + "n": "Shade" + }, + { + "t": 1, + "d": 2 + }, + { + "t": 8, + "d": 1, + "n": "Shade" + }, + { + "t": 1, + "d": 2 + }, + { + "t": 8, + "d": 1, + "n": "Shade" + }, + { + "t": 1, + "d": 2 + }, + { + "t": 11, + "n": "Shade" + } + ], + "created": 1755806329, + "updated": 1755806335 + }, + { + "id": 18, + "user_id": 1, + "monster_id": 4, + "monster_hp": 4, + "monster_max_hp": 10, + "monster_sleep": 0, + "monster_immune": 0, + "uber_damage": 0, + "uber_defense": 0, + "first_strike": false, + "turn": 5, + "ran_away": false, + "victory": true, + "won": false, + "reward_gold": 0, + "reward_exp": 0, + "actions": [ + { + "t": 1, + "d": 2 + }, + { + "t": 8, + "d": 1, + "n": "Creature" + }, + { + "t": 1, + "d": 2 + }, + { + "t": 8, + "d": 1, + "n": "Creature" + }, + { + "t": 1, + "d": 2 + }, + { + "t": 8, + "d": 1, + "n": "Creature" + }, + { + "t": 7, + "n": "You failed to run away!" + }, + { + "t": 8, + "d": 1, + "n": "Creature" + }, + { + "t": 7, + "n": "You failed to run away!" + }, + { + "t": 8, + "d": 1, + "n": "Creature" + } + ], + "created": 1755806697, + "updated": 1755806703 } ] \ No newline at end of file diff --git a/go.mod b/go.mod index c3b73cb..95113cd 100644 --- a/go.mod +++ b/go.mod @@ -10,8 +10,19 @@ require ( require ( github.com/andybalholm/brotli v1.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect golang.org/x/crypto v0.41.0 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/sys v0.35.0 // indirect + modernc.org/libc v1.65.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.37.1 // indirect + zombiezen.com/go/sqlite v1.4.2 // indirect ) diff --git a/go.sum b/go.sum index bc03a56..256a47a 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,18 @@ git.sharkk.net/Sharkk/Sushi v1.1.1 h1:ynU16l6vAhY/JUwHlI4zMQiPuL9lcs88W/mAGZsL4R git.sharkk.net/Sharkk/Sushi v1.1.1/go.mod h1:S84ACGkuZ+BKzBO4lb5WQnm5aw9+l7VSO2T1bjzxL3o= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= @@ -14,5 +24,18 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= +modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= +modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= +zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo= +zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc= diff --git a/internal/database/wrapper.go b/internal/database/wrapper.go new file mode 100644 index 0000000..0494c44 --- /dev/null +++ b/internal/database/wrapper.go @@ -0,0 +1,351 @@ +package database + +import ( + "fmt" + "reflect" + "regexp" + "strconv" + "strings" + "unicode" + + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" +) + +var placeholderRegex = regexp.MustCompile(`%[sd]`) + +var db *sqlite.Conn + +// Init initializes the database connection with WAL mode and cache settings +func Init(dbPath string) error { + conn, err := sqlite.OpenConn(dbPath, sqlite.OpenReadWrite|sqlite.OpenCreate) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + + // Enable WAL mode + if err := sqlitex.Execute(conn, "PRAGMA journal_mode=WAL", nil); err != nil { + conn.Close() + return fmt.Errorf("failed to enable WAL mode: %w", err) + } + + // Set generous cache size (64MB) + if err := sqlitex.Execute(conn, "PRAGMA cache_size=-65536", nil); err != nil { + conn.Close() + return fmt.Errorf("failed to set cache size: %w", err) + } + + // Enable foreign keys + if err := sqlitex.Execute(conn, "PRAGMA foreign_keys=ON", nil); err != nil { + conn.Close() + return fmt.Errorf("failed to enable foreign keys: %w", err) + } + + db = conn + return nil +} + +// DB returns the global database connection +func DB() *sqlite.Conn { + if db == nil { + panic("database not initialized - call Init() first") + } + return db +} + +// Scan scans a SQLite statement result into a struct using field names +func Scan(stmt *sqlite.Stmt, dest any) error { + v := reflect.ValueOf(dest) + if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct { + return fmt.Errorf("dest must be a pointer to struct") + } + + elem := v.Elem() + typ := elem.Type() + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + columnName := toSnakeCase(field.Name) + + fieldValue := elem.Field(i) + if !fieldValue.CanSet() { + continue + } + + // Find column index by name + colIndex := -1 + for j := 0; j < stmt.ColumnCount(); j++ { + if stmt.ColumnName(j) == columnName { + colIndex = j + break + } + } + + if colIndex == -1 { + continue // Column not found + } + + switch fieldValue.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + fieldValue.SetInt(stmt.ColumnInt64(colIndex)) + case reflect.String: + fieldValue.SetString(stmt.ColumnText(colIndex)) + case reflect.Float32, reflect.Float64: + fieldValue.SetFloat(stmt.ColumnFloat(colIndex)) + case reflect.Bool: + fieldValue.SetBool(stmt.ColumnInt(colIndex) != 0) + } + } + + return nil +} + +// Query executes a query with fmt-style placeholders and automatically binds parameters +func Query(query string, args ...any) (*sqlite.Stmt, error) { + // Replace fmt placeholders with SQLite placeholders + convertedQuery, paramTypes := convertPlaceholders(query) + + stmt, err := DB().Prepare(convertedQuery) + if err != nil { + return nil, err + } + + // Bind parameters with correct types + for i, arg := range args { + if i >= len(paramTypes) { + break + } + + switch paramTypes[i] { + case "s": // string + if s, ok := arg.(string); ok { + stmt.BindText(i+1, s) + } else { + stmt.BindText(i+1, fmt.Sprintf("%v", arg)) + } + case "d": // integer + switch v := arg.(type) { + case int: + stmt.BindInt64(i+1, int64(v)) + case int32: + stmt.BindInt64(i+1, int64(v)) + case int64: + stmt.BindInt64(i+1, v) + case float64: + stmt.BindInt64(i+1, int64(v)) + default: + if i64, err := strconv.ParseInt(fmt.Sprintf("%v", arg), 10, 64); err == nil { + stmt.BindInt64(i+1, i64) + } else { + stmt.BindInt64(i+1, 0) + } + } + } + } + + return stmt, nil +} + +// Get executes a query and returns the first row +func Get(dest any, query string, args ...any) error { + stmt, err := Query(query, args...) + if err != nil { + return err + } + defer stmt.Finalize() + + hasRow, err := stmt.Step() + if err != nil { + return err + } + if !hasRow { + return fmt.Errorf("no rows found") + } + + return Scan(stmt, dest) +} + +// Select executes a query and scans all rows into a slice +func Select(dest any, query string, args ...any) error { + destValue := reflect.ValueOf(dest) + if destValue.Kind() != reflect.Ptr || destValue.Elem().Kind() != reflect.Slice { + return fmt.Errorf("dest must be a pointer to slice") + } + + sliceValue := destValue.Elem() + elemType := sliceValue.Type().Elem() + + // Ensure element type is a pointer to struct + if elemType.Kind() != reflect.Ptr || elemType.Elem().Kind() != reflect.Struct { + return fmt.Errorf("slice elements must be pointers to structs") + } + + stmt, err := Query(query, args...) + if err != nil { + return err + } + defer stmt.Finalize() + + for { + hasRow, err := stmt.Step() + if err != nil { + return err + } + if !hasRow { + break + } + + // Create new instance of the element type + newElem := reflect.New(elemType.Elem()) + if err := Scan(stmt, newElem.Interface()); err != nil { + return err + } + + sliceValue.Set(reflect.Append(sliceValue, newElem)) + } + + return nil +} + +// Exec executes a statement with fmt-style placeholders +func Exec(query string, args ...any) error { + convertedQuery, paramTypes := convertPlaceholders(query) + + sqlArgs := make([]any, len(args)) + for i, arg := range args { + if i < len(paramTypes) && paramTypes[i] == "d" { + // Convert to int64 for integer parameters + switch v := arg.(type) { + case int: + sqlArgs[i] = int64(v) + case int32: + sqlArgs[i] = int64(v) + case int64: + sqlArgs[i] = v + default: + sqlArgs[i] = arg + } + } else { + sqlArgs[i] = arg + } + } + + return sqlitex.Execute(DB(), convertedQuery, &sqlitex.ExecOptions{ + Args: sqlArgs, + }) +} + +// Update updates specific fields in the database +func Update(tableName string, fields map[string]any, whereField string, whereValue any) error { + if len(fields) == 0 { + return nil // No changes + } + + // Build UPDATE query + setParts := make([]string, 0, len(fields)) + args := make([]any, 0, len(fields)+1) + + for field, value := range fields { + setParts = append(setParts, field+" = ?") + args = append(args, value) + } + + args = append(args, whereValue) + + query := fmt.Sprintf("UPDATE %s SET %s WHERE %s = ?", + tableName, strings.Join(setParts, ", "), whereField) + + return sqlitex.Execute(DB(), query, &sqlitex.ExecOptions{ + Args: args, + }) +} + +// Insert inserts a struct into the database +func Insert(tableName string, obj any, excludeFields ...string) (int64, error) { + v := reflect.ValueOf(obj) + if v.Kind() == reflect.Pointer { + v = v.Elem() + } + t := v.Type() + + exclude := make(map[string]bool) + for _, field := range excludeFields { + exclude[toSnakeCase(field)] = true + } + + var columns []string + var placeholders []string + var args []any + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + columnName := toSnakeCase(field.Name) + if exclude[columnName] { + continue + } + + columns = append(columns, columnName) + placeholders = append(placeholders, "?") + args = append(args, v.Field(i).Interface()) + } + + query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", + tableName, strings.Join(columns, ", "), strings.Join(placeholders, ", ")) + + stmt, err := DB().Prepare(query) + if err != nil { + return 0, err + } + defer stmt.Finalize() + + // Bind parameters + for i, arg := range args { + switch v := arg.(type) { + case string: + stmt.BindText(i+1, v) + case int, int32, int64: + stmt.BindInt64(i+1, reflect.ValueOf(v).Int()) + case float32, float64: + stmt.BindFloat(i+1, reflect.ValueOf(v).Float()) + default: + stmt.BindText(i+1, fmt.Sprintf("%v", v)) + } + } + + _, err = stmt.Step() + if err != nil { + return 0, err + } + + return DB().LastInsertRowID(), nil +} + +func convertPlaceholders(query string) (string, []string) { + var paramTypes []string + + convertedQuery := placeholderRegex.ReplaceAllStringFunc(query, func(match string) string { + paramTypes = append(paramTypes, match[1:]) // Remove % prefix + return "?" + }) + + return convertedQuery, paramTypes +} + +// toSnakeCase converts PascalCase to snake_case +func toSnakeCase(s string) string { + var result strings.Builder + runes := []rune(s) + + for i, r := range runes { + if i > 0 && unicode.IsUpper(r) { + // Don't add underscore if previous char was also uppercase + // unless next char is lowercase (end of acronym) + if !unicode.IsUpper(runes[i-1]) || + (i+1 < len(runes) && unicode.IsLower(runes[i+1])) { + result.WriteByte('_') + } + } + result.WriteRune(unicode.ToLower(r)) + } + return result.String() +} diff --git a/internal/models/towns/towns.go b/internal/models/towns/towns.go index b2ae8df..e70f366 100644 --- a/internal/models/towns/towns.go +++ b/internal/models/towns/towns.go @@ -5,80 +5,33 @@ import ( "math" "slices" "sort" - "strconv" - "strings" + "dk/internal/database" "dk/internal/helpers" - - nigiri "git.sharkk.net/Sharkk/Nigiri" ) // Town represents a town in the game type Town struct { - ID int `json:"id"` - Name string `json:"name" db:"required,unique"` - 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"` + ID int + Name string + X int + Y int + InnCost int + MapCost int + TPCost int + ShopList string } -// Global store -var store *nigiri.BaseStore[Town] - -// coordsKey creates a key for coordinate-based lookup -func coordsKey(x, y int) string { - return strconv.Itoa(x) + "," + strconv.Itoa(y) -} - -// Init sets up the Nigiri store and indices -func Init(collection *nigiri.Collection) { - store = nigiri.NewBaseStore[Town]() - - // Register custom indices - store.RegisterIndex("byName", nigiri.BuildCaseInsensitiveLookupIndex(func(t *Town) string { - return t.Name - })) - - store.RegisterIndex("byCoords", nigiri.BuildStringLookupIndex(func(t *Town) string { - return coordsKey(t.X, t.Y) - })) - - store.RegisterIndex("byInnCost", nigiri.BuildIntGroupIndex(func(t *Town) int { - return t.InnCost - })) - - store.RegisterIndex("byTPCost", nigiri.BuildIntGroupIndex(func(t *Town) int { - return t.TPCost - })) - - store.RegisterIndex("allByID", nigiri.BuildSortedListIndex(func(a, b *Town) bool { - return a.ID < b.ID - })) - - store.RebuildIndices() -} - -// GetStore returns the towns store -func GetStore() *nigiri.BaseStore[Town] { - if store == nil { - panic("towns store not initialized - call Initialize first") - } - return store -} - -// Creates a new Town with sensible defaults +// New creates a new Town with sensible defaults func New() *Town { return &Town{ Name: "", - X: 0, // Default coordinates + X: 0, Y: 0, - InnCost: 50, // Default inn cost - MapCost: 100, // Default map cost - TPCost: 25, // Default teleport cost - ShopList: "", // No items by default + InnCost: 50, + MapCost: 100, + TPCost: 25, + ShopList: "", } } @@ -100,85 +53,86 @@ func (t *Town) Validate() error { } // CRUD operations -func (t *Town) Save() error { - if t.ID == 0 { - id, err := store.Create(t) - if err != nil { - return err - } - t.ID = id - return nil - } - return store.Update(t.ID, t) -} - func (t *Town) Delete() error { - store.Remove(t.ID) - return nil + return database.Exec("DELETE FROM towns WHERE id = %d", t.ID) } -// Insert with ID assignment func (t *Town) Insert() error { - id, err := store.Create(t) + id, err := database.Insert("towns", t, "ID") if err != nil { return err } - t.ID = id + t.ID = int(id) return nil } // Query functions func Find(id int) (*Town, error) { - town, exists := store.Find(id) - if !exists { + var town Town + err := database.Get(&town, "SELECT * FROM towns WHERE id = %d", id) + if err != nil { return nil, fmt.Errorf("town with ID %d not found", id) } - return town, nil + return &town, nil } func All() ([]*Town, error) { - return store.AllSorted("allByID"), nil + var towns []*Town + err := database.Select(&towns, "SELECT * FROM towns ORDER BY id ASC") + return towns, err } func ByName(name string) (*Town, error) { - town, exists := store.LookupByIndex("byName", strings.ToLower(name)) - if !exists { + var town Town + err := database.Get(&town, "SELECT * FROM towns WHERE name = %s COLLATE NOCASE", name) + if err != nil { return nil, fmt.Errorf("town with name '%s' not found", name) } - return town, nil + return &town, nil } func ByMaxInnCost(maxCost int) ([]*Town, error) { - return store.FilterByIndex("allByID", func(t *Town) bool { - return t.InnCost <= maxCost - }), nil + var towns []*Town + err := database.Select(&towns, "SELECT * FROM towns WHERE inn_cost <= %d ORDER BY id ASC", maxCost) + return towns, err } func ByMaxTPCost(maxCost int) ([]*Town, error) { - return store.FilterByIndex("allByID", func(t *Town) bool { - return t.TPCost <= maxCost - }), nil + var towns []*Town + err := database.Select(&towns, "SELECT * FROM towns WHERE tp_cost <= %d ORDER BY id ASC", maxCost) + return towns, err } func ByCoords(x, y int) (*Town, error) { - town, exists := store.LookupByIndex("byCoords", coordsKey(x, y)) - if !exists { + var town Town + err := database.Get(&town, "SELECT * FROM towns WHERE x = %d AND y = %d", x, y) + if err != nil { return nil, nil // Return nil if not found (like original) } - return town, nil + return &town, nil } func ExistsAt(x, y int) bool { - _, exists := store.LookupByIndex("byCoords", coordsKey(x, y)) - return exists + var count int + err := database.Get(&count, "SELECT COUNT(*) FROM towns WHERE x = %d AND y = %d", x, y) + return err == nil && count > 0 } func ByDistance(fromX, fromY, maxDistance int) ([]*Town, error) { - maxDistance2 := float64(maxDistance * maxDistance) + var towns []*Town + err := database.Select(&towns, "SELECT * FROM towns") + if err != nil { + return nil, err + } - result := store.FilterByIndex("allByID", func(t *Town) bool { - return t.DistanceFromSquared(fromX, fromY) <= maxDistance2 - }) + maxDistance2 := float64(maxDistance * maxDistance) + var result []*Town + + for _, town := range towns { + if town.DistanceFromSquared(fromX, fromY) <= maxDistance2 { + result = append(result, town) + } + } // Sort by distance, then by ID sort.Slice(result, func(i, j int) bool { @@ -244,14 +198,3 @@ func (t *Town) SetPosition(x, y int) { t.X = x t.Y = y } - -// Legacy compatibility functions (will be removed later) -func LoadData(dataPath string) error { - // No longer needed - Nigiri handles this - return nil -} - -func SaveData(dataPath string) error { - // No longer needed - Nigiri handles this - return nil -} diff --git a/internal/models/users/users.go b/internal/models/users/users.go index e579c36..f9f77b1 100644 --- a/internal/models/users/users.go +++ b/internal/models/users/users.go @@ -3,109 +3,59 @@ package users import ( "fmt" "slices" - "sort" "strings" "time" + "dk/internal/database" "dk/internal/helpers" "dk/internal/helpers/exp" - - nigiri "git.sharkk.net/Sharkk/Nigiri" ) // User represents a user in the game type User struct { - ID int `json:"id"` - Username string `json:"username" db:"required,unique"` - Password string `json:"password" db:"required"` - Email string `json:"email" db:"required,unique"` - Verified int `json:"verified"` - Token string `json:"token"` - Registered int64 `json:"registered"` - LastOnline int64 `json:"last_online"` - Auth int `json:"auth"` - X int `json:"x"` - Y int `json:"y"` - ClassID int `json:"class_id"` - Currently string `json:"currently"` - FightID int `json:"fight_id"` - HP int `json:"hp"` - MP int `json:"mp"` - TP int `json:"tp"` - MaxHP int `json:"max_hp"` - MaxMP int `json:"max_mp"` - MaxTP int `json:"max_tp"` - Level int `json:"level" db:"index"` - Gold int `json:"gold"` - Exp int `json:"exp"` - GoldBonus int `json:"gold_bonus"` - ExpBonus int `json:"exp_bonus"` - Strength int `json:"strength"` - Dexterity int `json:"dexterity"` - Attack int `json:"attack"` - Defense int `json:"defense"` - WeaponID int `json:"weapon_id"` - ArmorID int `json:"armor_id"` - ShieldID int `json:"shield_id"` - Slot1ID int `json:"slot_1_id"` - Slot2ID int `json:"slot_2_id"` - Slot3ID int `json:"slot_3_id"` - WeaponName string `json:"weapon_name"` - ArmorName string `json:"armor_name"` - ShieldName string `json:"shield_name"` - Slot1Name string `json:"slot_1_name"` - Slot2Name string `json:"slot_2_name"` - Slot3Name string `json:"slot_3_name"` - Spells string `json:"spells"` - Towns string `json:"towns"` -} - -// Global store -var store *nigiri.BaseStore[User] - -// Init sets up the Nigiri store and indices -func Init(collection *nigiri.Collection) { - store = nigiri.NewBaseStore[User]() - - // Register custom indices - store.RegisterIndex("byUsername", nigiri.BuildCaseInsensitiveLookupIndex(func(u *User) string { - return u.Username - })) - - store.RegisterIndex("byEmail", nigiri.BuildStringLookupIndex(func(u *User) string { - return u.Email - })) - - store.RegisterIndex("byLevel", nigiri.BuildIntGroupIndex(func(u *User) int { - return u.Level - })) - - store.RegisterIndex("allByRegistered", nigiri.BuildSortedListIndex(func(a, b *User) bool { - if a.Registered != b.Registered { - return a.Registered > b.Registered // DESC - } - return a.ID > b.ID // DESC - })) - - store.RegisterIndex("allByLevelExp", nigiri.BuildSortedListIndex(func(a, b *User) bool { - if a.Level != b.Level { - return a.Level > b.Level // Level DESC - } - if a.Exp != b.Exp { - return a.Exp > b.Exp // Exp DESC - } - return a.ID < b.ID // ID ASC - })) - - store.RebuildIndices() -} - -// GetStore returns the users store -func GetStore() *nigiri.BaseStore[User] { - if store == nil { - panic("users store not initialized - call Initialize first") - } - return store + ID int + Username string + Password string + Email string + Verified int + Token string + Registered int64 + LastOnline int64 + Auth int + X int + Y int + ClassID int + Currently string + FightID int + HP int + MP int + TP int + MaxHP int + MaxMP int + MaxTP int + Level int + Gold int + Exp int + GoldBonus int + ExpBonus int + Strength int + Dexterity int + Attack int + Defense int + WeaponID int + ArmorID int + ShieldID int + Slot1ID int + Slot2ID int + Slot3ID int + WeaponName string + ArmorName string + ShieldName string + Slot1Name string + Slot2Name string + Slot3Name string + Spells string + Towns string } // New creates a new User with sensible defaults @@ -166,94 +116,73 @@ func (u *User) Validate() error { return nil } -// CRUD operations -func (u *User) Save() error { - if u.ID == 0 { - id, err := store.Create(u) - if err != nil { - return err - } - u.ID = id - return nil - } - return store.Update(u.ID, u) -} - func (u *User) Delete() error { - store.Remove(u.ID) - return nil + return database.Exec("DELETE FROM users WHERE id = %d", u.ID) } -// Insert with ID assignment func (u *User) Insert() error { - id, err := store.Create(u) + id, err := database.Insert("users", u, "id") if err != nil { return err } - u.ID = id + u.ID = int(id) return nil } -// Query functions func Find(id int) (*User, error) { - user, exists := store.Find(id) - if !exists { + var user User + err := database.Get(&user, "SELECT * FROM users WHERE id = %d", id) + if err != nil { return nil, fmt.Errorf("user with ID %d not found", id) } - return user, nil + return &user, nil } func GetByID(id int) *User { - user, exists := store.Find(id) - if !exists { + user, err := Find(id) + if err != nil { return nil } return user } func All() ([]*User, error) { - return store.AllSorted("allByRegistered"), nil + var users []*User + err := database.Select(&users, "SELECT * FROM users ORDER BY registered DESC, id DESC") + return users, err } func ByUsername(username string) (*User, error) { - user, exists := store.LookupByIndex("byUsername", strings.ToLower(username)) - if !exists { + var user User + err := database.Get(&user, "SELECT * FROM users WHERE username = %s COLLATE NOCASE", username) + if err != nil { return nil, fmt.Errorf("user with username '%s' not found", username) } - return user, nil + return &user, nil } func ByEmail(email string) (*User, error) { - user, exists := store.LookupByIndex("Email_idx", email) - if !exists { + var user User + err := database.Get(&user, "SELECT * FROM users WHERE email = %s", email) + if err != nil { return nil, fmt.Errorf("user with email '%s' not found", email) } - return user, nil + return &user, nil } func ByLevel(level int) ([]*User, error) { - return store.GroupByIndex("level_idx", level), nil + var users []*User + err := database.Select(&users, "SELECT * FROM users WHERE level = %d ORDER BY exp DESC, id ASC", level) + return users, err } func Online(within time.Duration) ([]*User, error) { cutoff := time.Now().Add(-within).Unix() - - result := store.FilterByIndex("allByRegistered", func(u *User) bool { - return u.LastOnline >= cutoff - }) - - // Sort by last_online DESC, then ID ASC - sort.Slice(result, func(i, j int) bool { - if result[i].LastOnline != result[j].LastOnline { - return result[i].LastOnline > result[j].LastOnline // DESC - } - return result[i].ID < result[j].ID // ASC - }) - - return result, nil + var users []*User + err := database.Select(&users, "SELECT * FROM users WHERE last_online >= %d ORDER BY last_online DESC, id ASC", cutoff) + return users, err } -// Helper methods func (u *User) RegisteredTime() time.Time { return time.Unix(u.Registered, 0) }