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,
"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 (
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
)

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

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

View File

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