first pass migrate back to sqlite

This commit is contained in:
Sky Johnson 2025-08-21 18:00:46 -05:00
parent 68ec8ce5ea
commit 412baeb46d
6 changed files with 652 additions and 253 deletions

View File

@ -962,5 +962,147 @@
], ],
"created": 1755789095, "created": 1755789095,
"updated": 1755789098 "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
} }
] ]

11
go.mod
View File

@ -10,8 +10,19 @@ require (
require ( require (
github.com/andybalholm/brotli v1.2.0 // indirect 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/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 github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/crypto v0.41.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 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
) )

23
go.sum
View File

@ -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= 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 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 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 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= 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= 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 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 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 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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=

View File

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

View File

@ -5,80 +5,33 @@ import (
"math" "math"
"slices" "slices"
"sort" "sort"
"strconv"
"strings"
"dk/internal/database"
"dk/internal/helpers" "dk/internal/helpers"
nigiri "git.sharkk.net/Sharkk/Nigiri"
) )
// Town represents a town in the game // Town represents a town in the game
type Town struct { type Town struct {
ID int `json:"id"` ID int
Name string `json:"name" db:"required,unique"` Name string
X int `json:"x"` X int
Y int `json:"y"` Y int
InnCost int `json:"inn_cost"` InnCost int
MapCost int `json:"map_cost"` MapCost int
TPCost int `json:"tp_cost"` TPCost int
ShopList string `json:"shop_list"` ShopList string
} }
// Global store // New creates a new Town with sensible defaults
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
func New() *Town { func New() *Town {
return &Town{ return &Town{
Name: "", Name: "",
X: 0, // Default coordinates X: 0,
Y: 0, Y: 0,
InnCost: 50, // Default inn cost InnCost: 50,
MapCost: 100, // Default map cost MapCost: 100,
TPCost: 25, // Default teleport cost TPCost: 25,
ShopList: "", // No items by default ShopList: "",
} }
} }
@ -100,85 +53,86 @@ func (t *Town) Validate() error {
} }
// CRUD operations // 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 { func (t *Town) Delete() error {
store.Remove(t.ID) return database.Exec("DELETE FROM towns WHERE id = %d", t.ID)
return nil
} }
// Insert with ID assignment
func (t *Town) Insert() error { func (t *Town) Insert() error {
id, err := store.Create(t) id, err := database.Insert("towns", t, "ID")
if err != nil { if err != nil {
return err return err
} }
t.ID = id t.ID = int(id)
return nil return nil
} }
// Query functions // Query functions
func Find(id int) (*Town, error) { func Find(id int) (*Town, error) {
town, exists := store.Find(id) var town Town
if !exists { 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 nil, fmt.Errorf("town with ID %d not found", id)
} }
return town, nil return &town, nil
} }
func All() ([]*Town, error) { 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) { func ByName(name string) (*Town, error) {
town, exists := store.LookupByIndex("byName", strings.ToLower(name)) var town Town
if !exists { 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 nil, fmt.Errorf("town with name '%s' not found", name)
} }
return town, nil return &town, nil
} }
func ByMaxInnCost(maxCost int) ([]*Town, error) { func ByMaxInnCost(maxCost int) ([]*Town, error) {
return store.FilterByIndex("allByID", func(t *Town) bool { var towns []*Town
return t.InnCost <= maxCost err := database.Select(&towns, "SELECT * FROM towns WHERE inn_cost <= %d ORDER BY id ASC", maxCost)
}), nil return towns, err
} }
func ByMaxTPCost(maxCost int) ([]*Town, error) { func ByMaxTPCost(maxCost int) ([]*Town, error) {
return store.FilterByIndex("allByID", func(t *Town) bool { var towns []*Town
return t.TPCost <= maxCost err := database.Select(&towns, "SELECT * FROM towns WHERE tp_cost <= %d ORDER BY id ASC", maxCost)
}), nil return towns, err
} }
func ByCoords(x, y int) (*Town, error) { func ByCoords(x, y int) (*Town, error) {
town, exists := store.LookupByIndex("byCoords", coordsKey(x, y)) var town Town
if !exists { 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 nil, nil // Return nil if not found (like original)
} }
return town, nil return &town, nil
} }
func ExistsAt(x, y int) bool { func ExistsAt(x, y int) bool {
_, exists := store.LookupByIndex("byCoords", coordsKey(x, y)) var count int
return exists 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) { 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 { maxDistance2 := float64(maxDistance * maxDistance)
return t.DistanceFromSquared(fromX, fromY) <= maxDistance2 var result []*Town
})
for _, town := range towns {
if town.DistanceFromSquared(fromX, fromY) <= maxDistance2 {
result = append(result, town)
}
}
// Sort by distance, then by ID // Sort by distance, then by ID
sort.Slice(result, func(i, j int) bool { sort.Slice(result, func(i, j int) bool {
@ -244,14 +198,3 @@ func (t *Town) SetPosition(x, y int) {
t.X = x t.X = x
t.Y = y 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
}

View File

@ -3,109 +3,59 @@ package users
import ( import (
"fmt" "fmt"
"slices" "slices"
"sort"
"strings" "strings"
"time" "time"
"dk/internal/database"
"dk/internal/helpers" "dk/internal/helpers"
"dk/internal/helpers/exp" "dk/internal/helpers/exp"
nigiri "git.sharkk.net/Sharkk/Nigiri"
) )
// User represents a user in the game // User represents a user in the game
type User struct { type User struct {
ID int `json:"id"` ID int
Username string `json:"username" db:"required,unique"` Username string
Password string `json:"password" db:"required"` Password string
Email string `json:"email" db:"required,unique"` Email string
Verified int `json:"verified"` Verified int
Token string `json:"token"` Token string
Registered int64 `json:"registered"` Registered int64
LastOnline int64 `json:"last_online"` LastOnline int64
Auth int `json:"auth"` Auth int
X int `json:"x"` X int
Y int `json:"y"` Y int
ClassID int `json:"class_id"` ClassID int
Currently string `json:"currently"` Currently string
FightID int `json:"fight_id"` FightID int
HP int `json:"hp"` HP int
MP int `json:"mp"` MP int
TP int `json:"tp"` TP int
MaxHP int `json:"max_hp"` MaxHP int
MaxMP int `json:"max_mp"` MaxMP int
MaxTP int `json:"max_tp"` MaxTP int
Level int `json:"level" db:"index"` Level int
Gold int `json:"gold"` Gold int
Exp int `json:"exp"` Exp int
GoldBonus int `json:"gold_bonus"` GoldBonus int
ExpBonus int `json:"exp_bonus"` ExpBonus int
Strength int `json:"strength"` Strength int
Dexterity int `json:"dexterity"` Dexterity int
Attack int `json:"attack"` Attack int
Defense int `json:"defense"` Defense int
WeaponID int `json:"weapon_id"` WeaponID int
ArmorID int `json:"armor_id"` ArmorID int
ShieldID int `json:"shield_id"` ShieldID int
Slot1ID int `json:"slot_1_id"` Slot1ID int
Slot2ID int `json:"slot_2_id"` Slot2ID int
Slot3ID int `json:"slot_3_id"` Slot3ID int
WeaponName string `json:"weapon_name"` WeaponName string
ArmorName string `json:"armor_name"` ArmorName string
ShieldName string `json:"shield_name"` ShieldName string
Slot1Name string `json:"slot_1_name"` Slot1Name string
Slot2Name string `json:"slot_2_name"` Slot2Name string
Slot3Name string `json:"slot_3_name"` Slot3Name string
Spells string `json:"spells"` Spells string
Towns string `json:"towns"` Towns string
}
// 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
} }
// New creates a new User with sensible defaults // New creates a new User with sensible defaults
@ -166,94 +116,73 @@ func (u *User) Validate() error {
return nil 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 { func (u *User) Delete() error {
store.Remove(u.ID) return database.Exec("DELETE FROM users WHERE id = %d", u.ID)
return nil
} }
// Insert with ID assignment
func (u *User) Insert() error { func (u *User) Insert() error {
id, err := store.Create(u) id, err := database.Insert("users", u, "id")
if err != nil { if err != nil {
return err return err
} }
u.ID = id u.ID = int(id)
return nil return nil
} }
// Query functions
func Find(id int) (*User, error) { func Find(id int) (*User, error) {
user, exists := store.Find(id) var user User
if !exists { 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 nil, fmt.Errorf("user with ID %d not found", id)
} }
return user, nil return &user, nil
} }
func GetByID(id int) *User { func GetByID(id int) *User {
user, exists := store.Find(id) user, err := Find(id)
if !exists { if err != nil {
return nil return nil
} }
return user return user
} }
func All() ([]*User, error) { 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) { func ByUsername(username string) (*User, error) {
user, exists := store.LookupByIndex("byUsername", strings.ToLower(username)) var user User
if !exists { 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 nil, fmt.Errorf("user with username '%s' not found", username)
} }
return user, nil return &user, nil
} }
func ByEmail(email string) (*User, error) { func ByEmail(email string) (*User, error) {
user, exists := store.LookupByIndex("Email_idx", email) var user User
if !exists { 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 nil, fmt.Errorf("user with email '%s' not found", email)
} }
return user, nil return &user, nil
} }
func ByLevel(level int) ([]*User, error) { 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) { func Online(within time.Duration) ([]*User, error) {
cutoff := time.Now().Add(-within).Unix() cutoff := time.Now().Add(-within).Unix()
var users []*User
result := store.FilterByIndex("allByRegistered", func(u *User) bool { err := database.Select(&users, "SELECT * FROM users WHERE last_online >= %d ORDER BY last_online DESC, id ASC", cutoff)
return u.LastOnline >= cutoff return users, err
})
// 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
} }
// Helper methods
func (u *User) RegisteredTime() time.Time { func (u *User) RegisteredTime() time.Time {
return time.Unix(u.Registered, 0) return time.Unix(u.Registered, 0)
} }