rework all models to new paradigm, fix ordered map to work with any interface correctly

This commit is contained in:
Sky Johnson 2025-08-12 20:00:49 -05:00
parent e3a125f6cf
commit 2b86e9fa79
15 changed files with 545 additions and 915 deletions

View File

@ -13,13 +13,39 @@ import (
// Babble represents a global chat message in the database // Babble represents a global chat message in the database
type Babble struct { type Babble struct {
database.BaseModel
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
Posted int64 `db:"posted" json:"posted"` Posted int64 `db:"posted" json:"posted"`
Author string `db:"author" json:"author"` Author string `db:"author" json:"author"`
Babble string `db:"babble" json:"babble"` Babble string `db:"babble" json:"babble"`
} }
// New creates a new Babble with sensible defaults func (b *Babble) GetTableName() string {
return "babble"
}
func (b *Babble) GetID() int {
return b.ID
}
func (b *Babble) SetID(id int) {
b.ID = id
}
func (b *Babble) Set(field string, value any) error {
return database.Set(b, field, value)
}
func (b *Babble) Save() error {
return database.Save(b)
}
func (b *Babble) Delete() error {
return database.Delete(b)
}
// Creates a new Babble with sensible defaults
func New() *Babble { func New() *Babble {
return &Babble{ return &Babble{
Posted: time.Now().Unix(), Posted: time.Now().Unix(),
@ -30,19 +56,19 @@ func New() *Babble {
var babbleScanner = scanner.New[Babble]() var babbleScanner = scanner.New[Babble]()
// babbleColumns returns the column list for babble queries // Returns the column list for babble queries
func babbleColumns() string { func babbleColumns() string {
return babbleScanner.Columns() return babbleScanner.Columns()
} }
// scanBabble populates a Babble struct using the fast scanner // Populates a Babble struct using the fast scanner
func scanBabble(stmt *sqlite.Stmt) *Babble { func scanBabble(stmt *sqlite.Stmt) *Babble {
babble := &Babble{} babble := &Babble{}
babbleScanner.Scan(stmt, babble) babbleScanner.Scan(stmt, babble)
return babble return babble
} }
// Find retrieves a babble message by ID // Retrieves a babble message by ID
func Find(id int) (*Babble, error) { func Find(id int) (*Babble, error) {
var babble *Babble var babble *Babble
@ -64,7 +90,7 @@ func Find(id int) (*Babble, error) {
return babble, nil return babble, nil
} }
// All retrieves all babble messages ordered by posted time (newest first) // Retrieves all babble messages ordered by posted time (newest first)
func All() ([]*Babble, error) { func All() ([]*Babble, error) {
var babbles []*Babble var babbles []*Babble
@ -83,7 +109,7 @@ func All() ([]*Babble, error) {
return babbles, nil return babbles, nil
} }
// ByAuthor retrieves babble messages by a specific author // Retrieves babble messages by a specific author
func ByAuthor(author string) ([]*Babble, error) { func ByAuthor(author string) ([]*Babble, error) {
var babbles []*Babble var babbles []*Babble
@ -102,7 +128,7 @@ func ByAuthor(author string) ([]*Babble, error) {
return babbles, nil return babbles, nil
} }
// Recent retrieves the most recent babble messages (limited by count) // Retrieves the most recent babble messages (limited by count)
func Recent(limit int) ([]*Babble, error) { func Recent(limit int) ([]*Babble, error) {
var babbles []*Babble var babbles []*Babble
@ -121,7 +147,7 @@ func Recent(limit int) ([]*Babble, error) {
return babbles, nil return babbles, nil
} }
// Since retrieves babble messages since a specific timestamp // Retrieves babble messages since a specific timestamp
func Since(since int64) ([]*Babble, error) { func Since(since int64) ([]*Babble, error) {
var babbles []*Babble var babbles []*Babble
@ -140,7 +166,7 @@ func Since(since int64) ([]*Babble, error) {
return babbles, nil return babbles, nil
} }
// Between retrieves babble messages between two timestamps (inclusive) // Retrieves babble messages between two timestamps (inclusive)
func Between(start, end int64) ([]*Babble, error) { func Between(start, end int64) ([]*Babble, error) {
var babbles []*Babble var babbles []*Babble
@ -159,7 +185,7 @@ func Between(start, end int64) ([]*Babble, error) {
return babbles, nil return babbles, nil
} }
// Search retrieves babble messages containing the search term (case-insensitive) // Retrieves babble messages containing the search term (case-insensitive)
func Search(term string) ([]*Babble, error) { func Search(term string) ([]*Babble, error) {
var babbles []*Babble var babbles []*Babble
@ -179,7 +205,7 @@ func Search(term string) ([]*Babble, error) {
return babbles, nil return babbles, nil
} }
// RecentByAuthor retrieves recent messages from a specific author // Retrieves recent messages from a specific author
func RecentByAuthor(author string, limit int) ([]*Babble, error) { func RecentByAuthor(author string, limit int) ([]*Babble, error) {
var babbles []*Babble var babbles []*Babble
@ -198,82 +224,39 @@ func RecentByAuthor(author string, limit int) ([]*Babble, error) {
return babbles, nil return babbles, nil
} }
// Save updates an existing babble message in the database // Saves a new babble to the database and sets the ID
func (b *Babble) Save() error {
if b.ID == 0 {
return fmt.Errorf("cannot save babble without ID")
}
query := `UPDATE babble SET posted = ?, author = ?, babble = ? WHERE id = ?`
return database.Exec(query, b.Posted, b.Author, b.Babble, b.ID)
}
// Insert saves a new babble to the database and sets the ID
func (b *Babble) Insert() error { func (b *Babble) Insert() error {
if b.ID != 0 { columns := `posted, author, babble`
return fmt.Errorf("babble already has ID %d, use Save() to update", b.ID) values := []any{b.Posted, b.Author, b.Babble}
} return database.Insert(b, columns, values...)
// Use a transaction to ensure we can get the ID
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO babble (posted, author, babble) VALUES (?, ?, ?)`
if err := tx.Exec(query, b.Posted, b.Author, b.Babble); err != nil {
return fmt.Errorf("failed to insert babble: %w", err)
}
// Get the last insert ID
var id int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
id = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get insert ID: %w", err)
}
b.ID = id
return nil
})
return err
} }
// Delete removes the babble message from the database // Returns the posted timestamp as a time.Time
func (b *Babble) Delete() error {
if b.ID == 0 {
return fmt.Errorf("cannot delete babble without ID")
}
return database.Exec("DELETE FROM babble WHERE id = ?", b.ID)
}
// PostedTime returns the posted timestamp as a time.Time
func (b *Babble) PostedTime() time.Time { func (b *Babble) PostedTime() time.Time {
return time.Unix(b.Posted, 0) return time.Unix(b.Posted, 0)
} }
// SetPostedTime sets the posted timestamp from a time.Time // Sets the posted timestamp from a time.Time
func (b *Babble) SetPostedTime(t time.Time) { func (b *Babble) SetPostedTime(t time.Time) {
b.Posted = t.Unix() b.Set("Posted", t.Unix())
} }
// IsRecent returns true if the babble message was posted within the last hour // Returns true if the babble message was posted within the last hour
func (b *Babble) IsRecent() bool { func (b *Babble) IsRecent() bool {
return time.Since(b.PostedTime()) < time.Hour return time.Since(b.PostedTime()) < time.Hour
} }
// Age returns how long ago the babble message was posted // Returns how long ago the babble message was posted
func (b *Babble) Age() time.Duration { func (b *Babble) Age() time.Duration {
return time.Since(b.PostedTime()) return time.Since(b.PostedTime())
} }
// IsAuthor returns true if the given username is the author of this babble message // Returns true if the given username is the author of this babble message
func (b *Babble) IsAuthor(username string) bool { func (b *Babble) IsAuthor(username string) bool {
return strings.EqualFold(b.Author, username) return strings.EqualFold(b.Author, username)
} }
// Preview returns a truncated version of the babble for previews // Returns a truncated version of the babble for previews
func (b *Babble) Preview(maxLength int) string { func (b *Babble) Preview(maxLength int) string {
if len(b.Babble) <= maxLength { if len(b.Babble) <= maxLength {
return b.Babble return b.Babble
@ -286,7 +269,7 @@ func (b *Babble) Preview(maxLength int) string {
return b.Babble[:maxLength-3] + "..." return b.Babble[:maxLength-3] + "..."
} }
// WordCount returns the number of words in the babble message // Returns the number of words in the babble message
func (b *Babble) WordCount() int { func (b *Babble) WordCount() int {
if b.Babble == "" { if b.Babble == "" {
return 0 return 0
@ -314,27 +297,27 @@ func (b *Babble) WordCount() int {
return words return words
} }
// Length returns the character length of the babble message // Returns the character length of the babble message
func (b *Babble) Length() int { func (b *Babble) Length() int {
return len(b.Babble) return len(b.Babble)
} }
// Contains returns true if the babble message contains the given term (case-insensitive) // Returns true if the babble message contains the given term (case-insensitive)
func (b *Babble) Contains(term string) bool { func (b *Babble) Contains(term string) bool {
return strings.Contains(strings.ToLower(b.Babble), strings.ToLower(term)) return strings.Contains(strings.ToLower(b.Babble), strings.ToLower(term))
} }
// IsEmpty returns true if the babble message is empty or whitespace-only // Returns true if the babble message is empty or whitespace-only
func (b *Babble) IsEmpty() bool { func (b *Babble) IsEmpty() bool {
return strings.TrimSpace(b.Babble) == "" return strings.TrimSpace(b.Babble) == ""
} }
// IsLongMessage returns true if the message exceeds the typical chat length // Returns true if the message exceeds the typical chat length
func (b *Babble) IsLongMessage(threshold int) bool { func (b *Babble) IsLongMessage(threshold int) bool {
return b.Length() > threshold return b.Length() > threshold
} }
// GetMentions returns a slice of usernames mentioned in the message (starting with @) // Returns a slice of usernames mentioned in the message (starting with @)
func (b *Babble) GetMentions() []string { func (b *Babble) GetMentions() []string {
words := strings.Fields(b.Babble) words := strings.Fields(b.Babble)
var mentions []string var mentions []string
@ -352,7 +335,7 @@ func (b *Babble) GetMentions() []string {
return mentions return mentions
} }
// HasMention returns true if the message mentions the given username // Returns true if the message mentions the given username
func (b *Babble) HasMention(username string) bool { func (b *Babble) HasMention(username string) bool {
mentions := b.GetMentions() mentions := b.GetMentions()
for _, mention := range mentions { for _, mention := range mentions {
@ -362,22 +345,3 @@ func (b *Babble) HasMention(username string) bool {
} }
return false return false
} }
// ToMap converts the babble to a map for efficient template rendering
func (b *Babble) ToMap() map[string]any {
return map[string]any{
"ID": b.ID,
"Posted": b.Posted,
"Author": b.Author,
"Babble": b.Babble,
// Computed values
"PostedTime": b.PostedTime(),
"IsRecent": b.IsRecent(),
"Age": b.Age(),
"WordCount": b.WordCount(),
"Length": b.Length(),
"IsEmpty": b.IsEmpty(),
"Mentions": b.GetMentions(),
}
}

View File

@ -11,6 +11,8 @@ import (
// Control represents the game control settings in the database // Control represents the game control settings in the database
type Control struct { type Control struct {
database.BaseModel
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
WorldSize int `db:"world_size" json:"world_size"` WorldSize int `db:"world_size" json:"world_size"`
Open int `db:"open" json:"open"` Open int `db:"open" json:"open"`
@ -20,7 +22,31 @@ type Control struct {
Class3Name string `db:"class_3_name" json:"class_3_name"` Class3Name string `db:"class_3_name" json:"class_3_name"`
} }
// New creates a new Control with sensible defaults func (c *Control) GetTableName() string {
return "control"
}
func (c *Control) GetID() int {
return c.ID
}
func (c *Control) SetID(id int) {
c.ID = id
}
func (c *Control) Set(field string, value any) error {
return database.Set(c, field, value)
}
func (c *Control) Save() error {
return database.Save(c)
}
func (c *Control) Delete() error {
return database.Delete(c)
}
// Creates a new Control with sensible defaults
func New() *Control { func New() *Control {
return &Control{ return &Control{
WorldSize: 200, // Default world size WorldSize: 200, // Default world size
@ -34,19 +60,19 @@ func New() *Control {
var controlScanner = scanner.New[Control]() var controlScanner = scanner.New[Control]()
// controlColumns returns the column list for control queries // Returns the column list for control queries
func controlColumns() string { func controlColumns() string {
return controlScanner.Columns() return controlScanner.Columns()
} }
// scanControl populates a Control struct using the fast scanner // Populates a Control struct using the fast scanner
func scanControl(stmt *sqlite.Stmt) *Control { func scanControl(stmt *sqlite.Stmt) *Control {
control := &Control{} control := &Control{}
controlScanner.Scan(stmt, control) controlScanner.Scan(stmt, control)
return control return control
} }
// Find retrieves the control record by ID (typically only ID 1 exists) // Retrieves the control record by ID (typically only ID 1 exists)
func Find(id int) (*Control, error) { func Find(id int) (*Control, error) {
var control *Control var control *Control
@ -68,86 +94,43 @@ func Find(id int) (*Control, error) {
return control, nil return control, nil
} }
// Get retrieves the main control record (ID 1) // Retrieves the main control record (ID 1)
func Get() (*Control, error) { func Get() (*Control, error) {
return Find(1) return Find(1)
} }
// Save updates the control record in the database // Saves a new control to the database and sets the ID
func (c *Control) Save() error {
if c.ID == 0 {
return fmt.Errorf("cannot save control without ID")
}
query := `UPDATE control SET world_size = ?, open = ?, admin_email = ?, class_1_name = ?, class_2_name = ?, class_3_name = ? WHERE id = ?`
return database.Exec(query, c.WorldSize, c.Open, c.AdminEmail, c.Class1Name, c.Class2Name, c.Class3Name, c.ID)
}
// Insert saves a new control to the database and sets the ID
func (c *Control) Insert() error { func (c *Control) Insert() error {
if c.ID != 0 { columns := `world_size, open, admin_email, class_1_name, class_2_name, class_3_name`
return fmt.Errorf("control already has ID %d, use Save() to update", c.ID) values := []any{c.WorldSize, c.Open, c.AdminEmail, c.Class1Name, c.Class2Name, c.Class3Name}
} return database.Insert(c, columns, values...)
// Use a transaction to ensure we can get the ID
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO control (world_size, open, admin_email, class_1_name, class_2_name, class_3_name) VALUES (?, ?, ?, ?, ?, ?)`
if err := tx.Exec(query, c.WorldSize, c.Open, c.AdminEmail, c.Class1Name, c.Class2Name, c.Class3Name); err != nil {
return fmt.Errorf("failed to insert control: %w", err)
}
// Get the last insert ID
var id int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
id = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get insert ID: %w", err)
}
c.ID = id
return nil
})
return err
} }
// Delete removes the control record from the database // Returns true if the game world is open for new players
func (c *Control) Delete() error {
if c.ID == 0 {
return fmt.Errorf("cannot delete control without ID")
}
return database.Exec("DELETE FROM control WHERE id = ?", c.ID)
}
// IsOpen returns true if the game world is open for new players
func (c *Control) IsOpen() bool { func (c *Control) IsOpen() bool {
return c.Open == 1 return c.Open == 1
} }
// SetOpen sets whether the game world is open for new players // Sets whether the game world is open for new players
func (c *Control) SetOpen(open bool) { func (c *Control) SetOpen(open bool) {
if open { if open {
c.Open = 1 c.Set("Open", 1)
} else { } else {
c.Open = 0 c.Set("Open", 0)
} }
} }
// Close closes the game world to new players // Closes the game world to new players
func (c *Control) Close() { func (c *Control) Close() {
c.Open = 0 c.Set("Open", 0)
} }
// OpenWorld opens the game world to new players // Opens the game world to new players
func (c *Control) OpenWorld() { func (c *Control) OpenWorld() {
c.Open = 1 c.Set("Open", 1)
} }
// GetClassNames returns all class names as a slice // Returns all class names as a slice
func (c *Control) GetClassNames() []string { func (c *Control) GetClassNames() []string {
classes := make([]string, 0, 3) classes := make([]string, 0, 3)
if c.Class1Name != "" { if c.Class1Name != "" {
@ -162,26 +145,26 @@ func (c *Control) GetClassNames() []string {
return classes return classes
} }
// SetClassNames sets all class names from a slice // Sets all class names from a slice
func (c *Control) SetClassNames(classes []string) { func (c *Control) SetClassNames(classes []string) {
// Reset all class names // Reset all class names
c.Class1Name = "" c.Set("Class1Name", "")
c.Class2Name = "" c.Set("Class2Name", "")
c.Class3Name = "" c.Set("Class3Name", "")
// Set provided class names // Set provided class names
if len(classes) > 0 { if len(classes) > 0 {
c.Class1Name = classes[0] c.Set("Class1Name", classes[0])
} }
if len(classes) > 1 { if len(classes) > 1 {
c.Class2Name = classes[1] c.Set("Class2Name", classes[1])
} }
if len(classes) > 2 { if len(classes) > 2 {
c.Class3Name = classes[2] c.Set("Class3Name", classes[2])
} }
} }
// GetClassName returns the name of a specific class (1-3) // Returns the name of a specific class (1-3)
func (c *Control) GetClassName(classNum int) string { func (c *Control) GetClassName(classNum int) string {
switch classNum { switch classNum {
case 1: case 1:
@ -195,24 +178,24 @@ func (c *Control) GetClassName(classNum int) string {
} }
} }
// SetClassName sets the name of a specific class (1-3) // Sets the name of a specific class (1-3)
func (c *Control) SetClassName(classNum int, name string) bool { func (c *Control) SetClassName(classNum int, name string) bool {
switch classNum { switch classNum {
case 1: case 1:
c.Class1Name = name c.Set("Class1Name", name)
return true return true
case 2: case 2:
c.Class2Name = name c.Set("Class2Name", name)
return true return true
case 3: case 3:
c.Class3Name = name c.Set("Class3Name", name)
return true return true
default: default:
return false return false
} }
} }
// IsValidClassName returns true if the given name matches one of the configured classes // Returns true if the given name matches one of the configured classes
func (c *Control) IsValidClassName(name string) bool { func (c *Control) IsValidClassName(name string) bool {
if name == "" { if name == "" {
return false return false
@ -220,7 +203,7 @@ func (c *Control) IsValidClassName(name string) bool {
return name == c.Class1Name || name == c.Class2Name || name == c.Class3Name return name == c.Class1Name || name == c.Class2Name || name == c.Class3Name
} }
// GetClassNumber returns the class number (1-3) for a given class name, or 0 if not found // Returns the class number (1-3) for a given class name, or 0 if not found
func (c *Control) GetClassNumber(name string) int { func (c *Control) GetClassNumber(name string) int {
if name == c.Class1Name && name != "" { if name == c.Class1Name && name != "" {
return 1 return 1
@ -234,55 +217,29 @@ func (c *Control) GetClassNumber(name string) int {
return 0 return 0
} }
// HasAdminEmail returns true if an admin email is configured // Returns true if an admin email is configured
func (c *Control) HasAdminEmail() bool { func (c *Control) HasAdminEmail() bool {
return c.AdminEmail != "" return c.AdminEmail != ""
} }
// IsWorldSizeValid returns true if the world size is within reasonable bounds // Returns true if the world size is within reasonable bounds
func (c *Control) IsWorldSizeValid() bool { func (c *Control) IsWorldSizeValid() bool {
return c.WorldSize > 0 && c.WorldSize <= 10000 return c.WorldSize > 0 && c.WorldSize <= 10000
} }
// GetWorldRadius returns the world radius (half the world size) // Returns the world radius (half the world size)
func (c *Control) GetWorldRadius() int { func (c *Control) GetWorldRadius() int {
return c.WorldSize / 2 return c.WorldSize / 2
} }
// IsWithinWorldBounds returns true if the given coordinates are within world bounds // Returns true if the given coordinates are within world bounds
func (c *Control) IsWithinWorldBounds(x, y int) bool { func (c *Control) IsWithinWorldBounds(x, y int) bool {
radius := c.GetWorldRadius() radius := c.GetWorldRadius()
return x >= -radius && x <= radius && y >= -radius && y <= radius return x >= -radius && x <= radius && y >= -radius && y <= radius
} }
// GetWorldBounds returns the minimum and maximum coordinates for the world // Returns the minimum and maximum coordinates for the world
func (c *Control) GetWorldBounds() (minX, minY, maxX, maxY int) { func (c *Control) GetWorldBounds() (minX, minY, maxX, maxY int) {
radius := c.GetWorldRadius() radius := c.GetWorldRadius()
return -radius, -radius, radius, radius return -radius, -radius, radius, radius
} }
// ToMap converts the control to a map for efficient template rendering
func (c *Control) ToMap() map[string]any {
return map[string]any{
"ID": c.ID,
"WorldSize": c.WorldSize,
"Open": c.Open,
"AdminEmail": c.AdminEmail,
"Class1Name": c.Class1Name,
"Class2Name": c.Class2Name,
"Class3Name": c.Class3Name,
// Computed values
"IsOpen": c.IsOpen(),
"ClassNames": c.GetClassNames(),
"HasAdminEmail": c.HasAdminEmail(),
"IsWorldSizeValid": c.IsWorldSizeValid(),
"WorldRadius": c.GetWorldRadius(),
"WorldBounds": map[string]int{
"MinX": -c.GetWorldRadius(),
"MinY": -c.GetWorldRadius(),
"MaxX": c.GetWorldRadius(),
"MaxY": c.GetWorldRadius(),
},
}
}

View File

@ -11,6 +11,8 @@ import (
// Drop represents a drop item in the database // Drop represents a drop item in the database
type Drop struct { type Drop struct {
database.BaseModel
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
Level int `db:"level" json:"level"` Level int `db:"level" json:"level"`
@ -18,7 +20,31 @@ type Drop struct {
Att string `db:"att" json:"att"` Att string `db:"att" json:"att"`
} }
// New creates a new Drop with sensible defaults func (d *Drop) GetTableName() string {
return "drops"
}
func (d *Drop) GetID() int {
return d.ID
}
func (d *Drop) SetID(id int) {
d.ID = id
}
func (d *Drop) Set(field string, value any) error {
return database.Set(d, field, value)
}
func (d *Drop) Save() error {
return database.Save(d)
}
func (d *Drop) Delete() error {
return database.Delete(d)
}
// Creates a new Drop with sensible defaults
func New() *Drop { func New() *Drop {
return &Drop{ return &Drop{
Name: "", Name: "",
@ -30,12 +56,12 @@ func New() *Drop {
var dropScanner = scanner.New[Drop]() var dropScanner = scanner.New[Drop]()
// dropColumns returns the column list for drop queries // Returns the column list for drop queries
func dropColumns() string { func dropColumns() string {
return dropScanner.Columns() return dropScanner.Columns()
} }
// scanDrop populates a Drop struct using the fast scanner // Populates a Drop struct using the fast scanner
func scanDrop(stmt *sqlite.Stmt) *Drop { func scanDrop(stmt *sqlite.Stmt) *Drop {
drop := &Drop{} drop := &Drop{}
dropScanner.Scan(stmt, drop) dropScanner.Scan(stmt, drop)
@ -47,7 +73,7 @@ const (
TypeConsumable = 1 TypeConsumable = 1
) )
// Find retrieves a drop by ID // Retrieves a drop by ID
func Find(id int) (*Drop, error) { func Find(id int) (*Drop, error) {
var drop *Drop var drop *Drop
@ -69,7 +95,7 @@ func Find(id int) (*Drop, error) {
return drop, nil return drop, nil
} }
// All retrieves all drops // Retrieves all drops
func All() ([]*Drop, error) { func All() ([]*Drop, error) {
var drops []*Drop var drops []*Drop
@ -88,7 +114,7 @@ func All() ([]*Drop, error) {
return drops, nil return drops, nil
} }
// ByLevel retrieves drops by minimum level requirement // Retrieves drops by minimum level requirement
func ByLevel(minLevel int) ([]*Drop, error) { func ByLevel(minLevel int) ([]*Drop, error) {
var drops []*Drop var drops []*Drop
@ -107,7 +133,7 @@ func ByLevel(minLevel int) ([]*Drop, error) {
return drops, nil return drops, nil
} }
// ByType retrieves drops by type // Retrieves drops by type
func ByType(dropType int) ([]*Drop, error) { func ByType(dropType int) ([]*Drop, error) {
var drops []*Drop var drops []*Drop
@ -126,62 +152,19 @@ func ByType(dropType int) ([]*Drop, error) {
return drops, nil return drops, nil
} }
// Save updates an existing drop in the database // Saves a new drop to the database and sets the ID
func (d *Drop) Save() error {
if d.ID == 0 {
return fmt.Errorf("cannot save drop without ID")
}
query := `UPDATE drops SET name = ?, level = ?, type = ?, att = ? WHERE id = ?`
return database.Exec(query, d.Name, d.Level, d.Type, d.Att, d.ID)
}
// Insert saves a new drop to the database and sets the ID
func (d *Drop) Insert() error { func (d *Drop) Insert() error {
if d.ID != 0 { columns := `name, level, type, att`
return fmt.Errorf("drop already has ID %d, use Save() to update", d.ID) values := []any{d.Name, d.Level, d.Type, d.Att}
} return database.Insert(d, columns, values...)
// Use a transaction to ensure we can get the ID
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO drops (name, level, type, att) VALUES (?, ?, ?, ?)`
if err := tx.Exec(query, d.Name, d.Level, d.Type, d.Att); err != nil {
return fmt.Errorf("failed to insert drop: %w", err)
}
// Get the last insert ID
var id int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
id = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get insert ID: %w", err)
}
d.ID = id
return nil
})
return err
} }
// Delete removes the drop from the database // Returns true if the drop is a consumable item
func (d *Drop) Delete() error {
if d.ID == 0 {
return fmt.Errorf("cannot delete drop without ID")
}
return database.Exec("DELETE FROM drops WHERE id = ?", d.ID)
}
// IsConsumable returns true if the drop is a consumable item
func (d *Drop) IsConsumable() bool { func (d *Drop) IsConsumable() bool {
return d.Type == TypeConsumable return d.Type == TypeConsumable
} }
// TypeName returns the string representation of the drop type // Returns the string representation of the drop type
func (d *Drop) TypeName() string { func (d *Drop) TypeName() string {
switch d.Type { switch d.Type {
case TypeConsumable: case TypeConsumable:
@ -190,18 +173,3 @@ func (d *Drop) TypeName() string {
return "Unknown" return "Unknown"
} }
} }
// ToMap converts the drop to a map for efficient template rendering
func (d *Drop) ToMap() map[string]any {
return map[string]any{
"ID": d.ID,
"Name": d.Name,
"Level": d.Level,
"Type": d.Type,
"Att": d.Att,
// Computed values
"IsConsumable": d.IsConsumable(),
"TypeName": d.TypeName(),
}
}

View File

@ -13,6 +13,8 @@ import (
// Forum represents a forum post or thread in the database // Forum represents a forum post or thread in the database
type Forum struct { type Forum struct {
database.BaseModel
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
Posted int64 `db:"posted" json:"posted"` Posted int64 `db:"posted" json:"posted"`
LastPost int64 `db:"last_post" json:"last_post"` LastPost int64 `db:"last_post" json:"last_post"`
@ -23,7 +25,31 @@ type Forum struct {
Content string `db:"content" json:"content"` Content string `db:"content" json:"content"`
} }
// New creates a new Forum with sensible defaults func (f *Forum) GetTableName() string {
return "forum"
}
func (f *Forum) GetID() int {
return f.ID
}
func (f *Forum) SetID(id int) {
f.ID = id
}
func (f *Forum) Set(field string, value any) error {
return database.Set(f, field, value)
}
func (f *Forum) Save() error {
return database.Save(f)
}
func (f *Forum) Delete() error {
return database.Delete(f)
}
// Creates a new Forum with sensible defaults
func New() *Forum { func New() *Forum {
now := time.Now().Unix() now := time.Now().Unix()
return &Forum{ return &Forum{
@ -39,19 +65,19 @@ func New() *Forum {
var forumScanner = scanner.New[Forum]() var forumScanner = scanner.New[Forum]()
// forumColumns returns the column list for forum queries // Returns the column list for forum queries
func forumColumns() string { func forumColumns() string {
return forumScanner.Columns() return forumScanner.Columns()
} }
// scanForum populates a Forum struct using the fast scanner // Populates a Forum struct using the fast scanner
func scanForum(stmt *sqlite.Stmt) *Forum { func scanForum(stmt *sqlite.Stmt) *Forum {
forum := &Forum{} forum := &Forum{}
forumScanner.Scan(stmt, forum) forumScanner.Scan(stmt, forum)
return forum return forum
} }
// Find retrieves a forum post by ID // Retrieves a forum post by ID
func Find(id int) (*Forum, error) { func Find(id int) (*Forum, error) {
var forum *Forum var forum *Forum
@ -73,7 +99,7 @@ func Find(id int) (*Forum, error) {
return forum, nil return forum, nil
} }
// All retrieves all forum posts ordered by last post time (most recent first) // Retrieves all forum posts ordered by last post time (most recent first)
func All() ([]*Forum, error) { func All() ([]*Forum, error) {
var forums []*Forum var forums []*Forum
@ -92,7 +118,7 @@ func All() ([]*Forum, error) {
return forums, nil return forums, nil
} }
// Threads retrieves all top-level forum threads (parent = 0) // Retrieves all top-level forum threads (parent = 0)
func Threads() ([]*Forum, error) { func Threads() ([]*Forum, error) {
var forums []*Forum var forums []*Forum
@ -111,7 +137,7 @@ func Threads() ([]*Forum, error) {
return forums, nil return forums, nil
} }
// ByParent retrieves all replies to a specific thread/post // Retrieves all replies to a specific thread/post
func ByParent(parentID int) ([]*Forum, error) { func ByParent(parentID int) ([]*Forum, error) {
var forums []*Forum var forums []*Forum
@ -130,7 +156,7 @@ func ByParent(parentID int) ([]*Forum, error) {
return forums, nil return forums, nil
} }
// ByAuthor retrieves forum posts by a specific author // Retrieves forum posts by a specific author
func ByAuthor(authorID int) ([]*Forum, error) { func ByAuthor(authorID int) ([]*Forum, error) {
var forums []*Forum var forums []*Forum
@ -149,7 +175,7 @@ func ByAuthor(authorID int) ([]*Forum, error) {
return forums, nil return forums, nil
} }
// Recent retrieves the most recent forum activity (limited by count) // Retrieves the most recent forum activity (limited by count)
func Recent(limit int) ([]*Forum, error) { func Recent(limit int) ([]*Forum, error) {
var forums []*Forum var forums []*Forum
@ -168,7 +194,7 @@ func Recent(limit int) ([]*Forum, error) {
return forums, nil return forums, nil
} }
// Search retrieves forum posts containing the search term in title or content // Retrieves forum posts containing the search term in title or content
func Search(term string) ([]*Forum, error) { func Search(term string) ([]*Forum, error) {
var forums []*Forum var forums []*Forum
@ -188,7 +214,7 @@ func Search(term string) ([]*Forum, error) {
return forums, nil return forums, nil
} }
// Since retrieves forum posts with activity since a specific timestamp // Retrieves forum posts with activity since a specific timestamp
func Since(since int64) ([]*Forum, error) { func Since(since int64) ([]*Forum, error) {
var forums []*Forum var forums []*Forum
@ -207,112 +233,69 @@ func Since(since int64) ([]*Forum, error) {
return forums, nil return forums, nil
} }
// Save updates an existing forum post in the database // Saves a new forum post to the database and sets the ID
func (f *Forum) Save() error {
if f.ID == 0 {
return fmt.Errorf("cannot save forum post without ID")
}
query := `UPDATE forum SET posted = ?, last_post = ?, author = ?, parent = ?, replies = ?, title = ?, content = ? WHERE id = ?`
return database.Exec(query, f.Posted, f.LastPost, f.Author, f.Parent, f.Replies, f.Title, f.Content, f.ID)
}
// Insert saves a new forum post to the database and sets the ID
func (f *Forum) Insert() error { func (f *Forum) Insert() error {
if f.ID != 0 { columns := `posted, last_post, author, parent, replies, title, content`
return fmt.Errorf("forum post already has ID %d, use Save() to update", f.ID) values := []any{f.Posted, f.LastPost, f.Author, f.Parent, f.Replies, f.Title, f.Content}
} return database.Insert(f, columns, values...)
// Use a transaction to ensure we can get the ID
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO forum (posted, last_post, author, parent, replies, title, content) VALUES (?, ?, ?, ?, ?, ?, ?)`
if err := tx.Exec(query, f.Posted, f.LastPost, f.Author, f.Parent, f.Replies, f.Title, f.Content); err != nil {
return fmt.Errorf("failed to insert forum post: %w", err)
}
// Get the last insert ID
var id int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
id = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get insert ID: %w", err)
}
f.ID = id
return nil
})
return err
} }
// Delete removes the forum post from the database // Returns the posted timestamp as a time.Time
func (f *Forum) Delete() error {
if f.ID == 0 {
return fmt.Errorf("cannot delete forum post without ID")
}
return database.Exec("DELETE FROM forum WHERE id = ?", f.ID)
}
// PostedTime returns the posted timestamp as a time.Time
func (f *Forum) PostedTime() time.Time { func (f *Forum) PostedTime() time.Time {
return time.Unix(f.Posted, 0) return time.Unix(f.Posted, 0)
} }
// LastPostTime returns the last post timestamp as a time.Time // Returns the last post timestamp as a time.Time
func (f *Forum) LastPostTime() time.Time { func (f *Forum) LastPostTime() time.Time {
return time.Unix(f.LastPost, 0) return time.Unix(f.LastPost, 0)
} }
// SetPostedTime sets the posted timestamp from a time.Time // Sets the posted timestamp from a time.Time
func (f *Forum) SetPostedTime(t time.Time) { func (f *Forum) SetPostedTime(t time.Time) {
f.Posted = t.Unix() f.Set("Posted", t.Unix())
} }
// SetLastPostTime sets the last post timestamp from a time.Time // Sets the last post timestamp from a time.Time
func (f *Forum) SetLastPostTime(t time.Time) { func (f *Forum) SetLastPostTime(t time.Time) {
f.LastPost = t.Unix() f.Set("LastPost", t.Unix())
} }
// IsThread returns true if this is a top-level thread (parent = 0) // Returns true if this is a top-level thread (parent = 0)
func (f *Forum) IsThread() bool { func (f *Forum) IsThread() bool {
return f.Parent == 0 return f.Parent == 0
} }
// IsReply returns true if this is a reply to another post (parent > 0) // Returns true if this is a reply to another post (parent > 0)
func (f *Forum) IsReply() bool { func (f *Forum) IsReply() bool {
return f.Parent > 0 return f.Parent > 0
} }
// HasReplies returns true if this post has replies // Returns true if this post has replies
func (f *Forum) HasReplies() bool { func (f *Forum) HasReplies() bool {
return f.Replies > 0 return f.Replies > 0
} }
// IsRecentActivity returns true if there has been activity within the last 24 hours // Returns true if there has been activity within the last 24 hours
func (f *Forum) IsRecentActivity() bool { func (f *Forum) IsRecentActivity() bool {
return time.Since(f.LastPostTime()) < 24*time.Hour return time.Since(f.LastPostTime()) < 24*time.Hour
} }
// ActivityAge returns how long ago the last activity occurred // Returns how long ago the last activity occurred
func (f *Forum) ActivityAge() time.Duration { func (f *Forum) ActivityAge() time.Duration {
return time.Since(f.LastPostTime()) return time.Since(f.LastPostTime())
} }
// PostAge returns how long ago the post was originally made // Returns how long ago the post was originally made
func (f *Forum) PostAge() time.Duration { func (f *Forum) PostAge() time.Duration {
return time.Since(f.PostedTime()) return time.Since(f.PostedTime())
} }
// IsAuthor returns true if the given user ID is the author of this post // Returns true if the given user ID is the author of this post
func (f *Forum) IsAuthor(userID int) bool { func (f *Forum) IsAuthor(userID int) bool {
return f.Author == userID return f.Author == userID
} }
// Preview returns a truncated version of the content for previews // Returns a truncated version of the content for previews
func (f *Forum) Preview(maxLength int) string { func (f *Forum) Preview(maxLength int) string {
if len(f.Content) <= maxLength { if len(f.Content) <= maxLength {
return f.Content return f.Content
@ -325,7 +308,7 @@ func (f *Forum) Preview(maxLength int) string {
return f.Content[:maxLength-3] + "..." return f.Content[:maxLength-3] + "..."
} }
// WordCount returns the number of words in the content // Returns the number of words in the content
func (f *Forum) WordCount() int { func (f *Forum) WordCount() int {
if f.Content == "" { if f.Content == "" {
return 0 return 0
@ -353,70 +336,44 @@ func (f *Forum) WordCount() int {
return words return words
} }
// Length returns the character length of the content // Returns the character length of the content
func (f *Forum) Length() int { func (f *Forum) Length() int {
return len(f.Content) return len(f.Content)
} }
// Contains returns true if the title or content contains the given term (case-insensitive) // Returns true if the title or content contains the given term (case-insensitive)
func (f *Forum) Contains(term string) bool { func (f *Forum) Contains(term string) bool {
lowerTerm := strings.ToLower(term) lowerTerm := strings.ToLower(term)
return strings.Contains(strings.ToLower(f.Title), lowerTerm) || return strings.Contains(strings.ToLower(f.Title), lowerTerm) ||
strings.Contains(strings.ToLower(f.Content), lowerTerm) strings.Contains(strings.ToLower(f.Content), lowerTerm)
} }
// UpdateLastPost updates the last_post timestamp to current time // Updates the last_post timestamp to current time
func (f *Forum) UpdateLastPost() { func (f *Forum) UpdateLastPost() {
f.LastPost = time.Now().Unix() f.Set("LastPost", time.Now().Unix())
} }
// IncrementReplies increments the reply count // Increments the reply count
func (f *Forum) IncrementReplies() { func (f *Forum) IncrementReplies() {
f.Replies++ f.Set("Replies", f.Replies+1)
} }
// DecrementReplies decrements the reply count (minimum 0) // Decrements the reply count (minimum 0)
func (f *Forum) DecrementReplies() { func (f *Forum) DecrementReplies() {
if f.Replies > 0 { if f.Replies > 0 {
f.Replies-- f.Set("Replies", f.Replies-1)
} }
} }
// GetReplies retrieves all direct replies to this post // Retrieves all direct replies to this post
func (f *Forum) GetReplies() ([]*Forum, error) { func (f *Forum) GetReplies() ([]*Forum, error) {
return ByParent(f.ID) return ByParent(f.ID)
} }
// GetThread retrieves the parent thread (if this is a reply) or returns self (if this is a thread) // Retrieves the parent thread (if this is a reply) or returns self (if this is a thread)
func (f *Forum) GetThread() (*Forum, error) { func (f *Forum) GetThread() (*Forum, error) {
if f.IsThread() { if f.IsThread() {
return f, nil return f, nil
} }
return Find(f.Parent) return Find(f.Parent)
} }
// ToMap converts the forum post to a map for efficient template rendering
func (f *Forum) ToMap() map[string]any {
return map[string]any{
"ID": f.ID,
"Posted": f.Posted,
"LastPost": f.LastPost,
"Author": f.Author,
"Parent": f.Parent,
"Replies": f.Replies,
"Title": f.Title,
"Content": f.Content,
// Computed values
"PostedTime": f.PostedTime(),
"LastPostTime": f.LastPostTime(),
"IsThread": f.IsThread(),
"IsReply": f.IsReply(),
"HasReplies": f.HasReplies(),
"IsRecentActivity": f.IsRecentActivity(),
"ActivityAge": f.ActivityAge(),
"PostAge": f.PostAge(),
"WordCount": f.WordCount(),
"Length": f.Length(),
}
}

View File

@ -27,13 +27,10 @@ func (om *OrderedMap[K, V]) Range(fn func(K, V) bool) {
} }
} }
func (om *OrderedMap[K, V]) ToSlice() []map[string]any { func (om *OrderedMap[K, V]) ToSlice() []V {
result := make([]map[string]any, 0, len(om.keys)) result := make([]V, 0, len(om.keys))
for _, key := range om.keys { for _, key := range om.keys {
result = append(result, map[string]any{ result = append(result, om.data[key])
"id": key,
"name": om.data[key],
})
} }
return result return result
} }

View File

@ -11,6 +11,8 @@ import (
// Item represents an item in the database // Item represents an item in the database
type Item struct { type Item struct {
database.BaseModel
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
Type int `db:"type" json:"type"` Type int `db:"type" json:"type"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
@ -19,7 +21,31 @@ type Item struct {
Special string `db:"special" json:"special"` Special string `db:"special" json:"special"`
} }
// New creates a new Item with sensible defaults func (i *Item) GetTableName() string {
return "items"
}
func (i *Item) GetID() int {
return i.ID
}
func (i *Item) SetID(id int) {
i.ID = id
}
func (i *Item) Set(field string, value any) error {
return database.Set(i, field, value)
}
func (i *Item) Save() error {
return database.Save(i)
}
func (i *Item) Delete() error {
return database.Delete(i)
}
// Creates a new Item with sensible defaults
func New() *Item { func New() *Item {
return &Item{ return &Item{
Type: TypeWeapon, // Default to weapon Type: TypeWeapon, // Default to weapon
@ -32,12 +58,12 @@ func New() *Item {
var itemScanner = scanner.New[Item]() var itemScanner = scanner.New[Item]()
// itemColumns returns the column list for item queries // Returns the column list for item queries
func itemColumns() string { func itemColumns() string {
return itemScanner.Columns() return itemScanner.Columns()
} }
// scanItem populates an Item struct using the fast scanner // Populates an Item struct using the fast scanner
func scanItem(stmt *sqlite.Stmt) *Item { func scanItem(stmt *sqlite.Stmt) *Item {
item := &Item{} item := &Item{}
itemScanner.Scan(stmt, item) itemScanner.Scan(stmt, item)
@ -51,7 +77,7 @@ const (
TypeShield = 3 TypeShield = 3
) )
// Find retrieves an item by ID // Retrieves an item by ID
func Find(id int) (*Item, error) { func Find(id int) (*Item, error) {
var item *Item var item *Item
@ -73,7 +99,7 @@ func Find(id int) (*Item, error) {
return item, nil return item, nil
} }
// All retrieves all items // Retrieves all items
func All() ([]*Item, error) { func All() ([]*Item, error) {
var items []*Item var items []*Item
@ -92,7 +118,7 @@ func All() ([]*Item, error) {
return items, nil return items, nil
} }
// ByType retrieves items by type // Retrieves items by type
func ByType(itemType int) ([]*Item, error) { func ByType(itemType int) ([]*Item, error) {
var items []*Item var items []*Item
@ -111,73 +137,29 @@ func ByType(itemType int) ([]*Item, error) {
return items, nil return items, nil
} }
// Save updates an existing item in the database // Saves a new item to the database and sets the ID
func (i *Item) Save() error {
if i.ID == 0 {
return fmt.Errorf("cannot save item without ID")
}
query := `UPDATE items SET type = ?, name = ?, value = ?, att = ?, special = ? WHERE id = ?`
return database.Exec(query, i.Type, i.Name, i.Value, i.Att, i.Special, i.ID)
}
// Insert saves a new item to the database and sets the ID
func (i *Item) Insert() error { func (i *Item) Insert() error {
if i.ID != 0 { columns := `type, name, value, att, special`
return fmt.Errorf("item already has ID %d, use Save() to update", i.ID) values := []any{i.Type, i.Name, i.Value, i.Att, i.Special}
} return database.Insert(i, columns, values...)
// Use a transaction to ensure we can get the ID
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO items (type, name, value, att, special) VALUES (?, ?, ?, ?, ?)`
if err := tx.Exec(query, i.Type, i.Name, i.Value, i.Att, i.Special); err != nil {
return fmt.Errorf("failed to insert item: %w", err)
}
// Get the last insert ID
var id int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
id = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get insert ID: %w", err)
}
i.ID = id
return nil
})
return err
} }
// Delete removes the item from the database // Returns true if the item is a weapon
func (i *Item) Delete() error {
if i.ID == 0 {
return fmt.Errorf("cannot delete item without ID")
}
query := "DELETE FROM items WHERE id = ?"
return database.Exec(query, i.ID)
}
// IsWeapon returns true if the item is a weapon
func (i *Item) IsWeapon() bool { func (i *Item) IsWeapon() bool {
return i.Type == TypeWeapon return i.Type == TypeWeapon
} }
// IsArmor returns true if the item is armor // Returns true if the item is armor
func (i *Item) IsArmor() bool { func (i *Item) IsArmor() bool {
return i.Type == TypeArmor return i.Type == TypeArmor
} }
// IsShield returns true if the item is a shield // Returns true if the item is a shield
func (i *Item) IsShield() bool { func (i *Item) IsShield() bool {
return i.Type == TypeShield return i.Type == TypeShield
} }
// TypeName returns the string representation of the item type // Returns the string representation of the item type
func (i *Item) TypeName() string { func (i *Item) TypeName() string {
switch i.Type { switch i.Type {
case TypeWeapon: case TypeWeapon:
@ -191,32 +173,12 @@ func (i *Item) TypeName() string {
} }
} }
// HasSpecial returns true if the item has special properties // Returns true if the item has special properties
func (i *Item) HasSpecial() bool { func (i *Item) HasSpecial() bool {
return i.Special != "" return i.Special != ""
} }
// IsEquippable returns true if the item can be equipped // Returns true if the item can be equipped
func (i *Item) IsEquippable() bool { func (i *Item) IsEquippable() bool {
return i.Type == TypeWeapon || i.Type == TypeArmor || i.Type == TypeShield return i.Type == TypeWeapon || i.Type == TypeArmor || i.Type == TypeShield
} }
// ToMap converts the item to a map for efficient template rendering
func (i *Item) ToMap() map[string]any {
return map[string]any{
"ID": i.ID,
"Type": i.Type,
"Name": i.Name,
"Value": i.Value,
"Att": i.Att,
"Special": i.Special,
// Computed values
"IsWeapon": i.IsWeapon(),
"IsArmor": i.IsArmor(),
"IsShield": i.IsShield(),
"TypeName": i.TypeName(),
"HasSpecial": i.HasSpecial(),
"IsEquippable": i.IsEquippable(),
}
}

View File

@ -11,6 +11,8 @@ import (
// Monster represents a monster in the database // Monster represents a monster in the database
type Monster struct { type Monster struct {
database.BaseModel
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
MaxHP int `db:"max_hp" json:"max_hp"` MaxHP int `db:"max_hp" json:"max_hp"`
@ -22,28 +24,52 @@ type Monster struct {
Immune int `db:"immune" json:"immune"` Immune int `db:"immune" json:"immune"`
} }
// New creates a new Monster with sensible defaults func (m *Monster) GetTableName() string {
return "monsters"
}
func (m *Monster) GetID() int {
return m.ID
}
func (m *Monster) SetID(id int) {
m.ID = id
}
func (m *Monster) Set(field string, value any) error {
return database.Set(m, field, value)
}
func (m *Monster) Save() error {
return database.Save(m)
}
func (m *Monster) Delete() error {
return database.Delete(m)
}
// Creates a new Monster with sensible defaults
func New() *Monster { func New() *Monster {
return &Monster{ return &Monster{
Name: "", Name: "",
MaxHP: 10, // Default HP MaxHP: 10, // Default HP
MaxDmg: 5, // Default damage MaxDmg: 5, // Default damage
Armor: 0, // Default armor Armor: 0, // Default armor
Level: 1, // Default level Level: 1, // Default level
MaxExp: 10, // Default exp reward MaxExp: 10, // Default exp reward
MaxGold: 5, // Default gold reward MaxGold: 5, // Default gold reward
Immune: ImmuneNone, // No immunity by default Immune: ImmuneNone, // No immunity by default
} }
} }
var monsterScanner = scanner.New[Monster]() var monsterScanner = scanner.New[Monster]()
// monsterColumns returns the column list for monster queries // Returns the column list for monster queries
func monsterColumns() string { func monsterColumns() string {
return monsterScanner.Columns() return monsterScanner.Columns()
} }
// scanMonster populates a Monster struct using the fast scanner // Populates a Monster struct using the fast scanner
func scanMonster(stmt *sqlite.Stmt) *Monster { func scanMonster(stmt *sqlite.Stmt) *Monster {
monster := &Monster{} monster := &Monster{}
monsterScanner.Scan(stmt, monster) monsterScanner.Scan(stmt, monster)
@ -57,7 +83,7 @@ const (
ImmuneSleep = 2 // Immune to Sleep spells ImmuneSleep = 2 // Immune to Sleep spells
) )
// Find retrieves a monster by ID // Retrieves a monster by ID
func Find(id int) (*Monster, error) { func Find(id int) (*Monster, error) {
var monster *Monster var monster *Monster
@ -79,7 +105,7 @@ func Find(id int) (*Monster, error) {
return monster, nil return monster, nil
} }
// All retrieves all monsters // Retrieves all monsters
func All() ([]*Monster, error) { func All() ([]*Monster, error) {
var monsters []*Monster var monsters []*Monster
@ -98,7 +124,7 @@ func All() ([]*Monster, error) {
return monsters, nil return monsters, nil
} }
// ByLevel retrieves monsters by level // Retrieves monsters by level
func ByLevel(level int) ([]*Monster, error) { func ByLevel(level int) ([]*Monster, error) {
var monsters []*Monster var monsters []*Monster
@ -117,7 +143,7 @@ func ByLevel(level int) ([]*Monster, error) {
return monsters, nil return monsters, nil
} }
// ByLevelRange retrieves monsters within a level range (inclusive) // Retrieves monsters within a level range (inclusive)
func ByLevelRange(minLevel, maxLevel int) ([]*Monster, error) { func ByLevelRange(minLevel, maxLevel int) ([]*Monster, error) {
var monsters []*Monster var monsters []*Monster
@ -136,7 +162,7 @@ func ByLevelRange(minLevel, maxLevel int) ([]*Monster, error) {
return monsters, nil return monsters, nil
} }
// ByImmunity retrieves monsters by immunity type // Retrieves monsters by immunity type
func ByImmunity(immunityType int) ([]*Monster, error) { func ByImmunity(immunityType int) ([]*Monster, error) {
var monsters []*Monster var monsters []*Monster
@ -155,73 +181,29 @@ func ByImmunity(immunityType int) ([]*Monster, error) {
return monsters, nil return monsters, nil
} }
// Save updates an existing monster in the database // Saves a new monster to the database and sets the ID
func (m *Monster) Save() error {
if m.ID == 0 {
return fmt.Errorf("cannot save monster without ID")
}
query := `UPDATE monsters SET name = ?, max_hp = ?, max_dmg = ?, armor = ?, level = ?, max_exp = ?, max_gold = ?, immune = ? WHERE id = ?`
return database.Exec(query, m.Name, m.MaxHP, m.MaxDmg, m.Armor, m.Level, m.MaxExp, m.MaxGold, m.Immune, m.ID)
}
// Insert saves a new monster to the database and sets the ID
func (m *Monster) Insert() error { func (m *Monster) Insert() error {
if m.ID != 0 { columns := `name, max_hp, max_dmg, armor, level, max_exp, max_gold, immune`
return fmt.Errorf("monster already has ID %d, use Save() to update", m.ID) values := []any{m.Name, m.MaxHP, m.MaxDmg, m.Armor, m.Level, m.MaxExp, m.MaxGold, m.Immune}
} return database.Insert(m, columns, values...)
// Use a transaction to ensure we can get the ID
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO monsters (name, max_hp, max_dmg, armor, level, max_exp, max_gold, immune) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
if err := tx.Exec(query, m.Name, m.MaxHP, m.MaxDmg, m.Armor, m.Level, m.MaxExp, m.MaxGold, m.Immune); err != nil {
return fmt.Errorf("failed to insert monster: %w", err)
}
// Get the last insert ID
var id int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
id = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get insert ID: %w", err)
}
m.ID = id
return nil
})
return err
} }
// Delete removes the monster from the database // Returns true if the monster is immune to Hurt spells
func (m *Monster) Delete() error {
if m.ID == 0 {
return fmt.Errorf("cannot delete monster without ID")
}
query := "DELETE FROM monsters WHERE id = ?"
return database.Exec(query, m.ID)
}
// IsHurtImmune returns true if the monster is immune to Hurt spells
func (m *Monster) IsHurtImmune() bool { func (m *Monster) IsHurtImmune() bool {
return m.Immune == ImmuneHurt return m.Immune == ImmuneHurt
} }
// IsSleepImmune returns true if the monster is immune to Sleep spells // Returns true if the monster is immune to Sleep spells
func (m *Monster) IsSleepImmune() bool { func (m *Monster) IsSleepImmune() bool {
return m.Immune == ImmuneSleep return m.Immune == ImmuneSleep
} }
// HasImmunity returns true if the monster has any immunity // Returns true if the monster has any immunity
func (m *Monster) HasImmunity() bool { func (m *Monster) HasImmunity() bool {
return m.Immune != ImmuneNone return m.Immune != ImmuneNone
} }
// ImmunityName returns the string representation of the monster's immunity // Returns the string representation of the monster's immunity
func (m *Monster) ImmunityName() string { func (m *Monster) ImmunityName() string {
switch m.Immune { switch m.Immune {
case ImmuneNone: case ImmuneNone:
@ -235,7 +217,7 @@ func (m *Monster) ImmunityName() string {
} }
} }
// DifficultyRating calculates a simple difficulty rating based on stats // Calculates a simple difficulty rating based on stats
func (m *Monster) DifficultyRating() float64 { func (m *Monster) DifficultyRating() float64 {
// Simple formula: (HP + Damage + Armor) / Level // Simple formula: (HP + Damage + Armor) / Level
// Higher values indicate tougher monsters relative to their level // Higher values indicate tougher monsters relative to their level
@ -245,7 +227,7 @@ func (m *Monster) DifficultyRating() float64 {
return float64(m.MaxHP+m.MaxDmg+m.Armor) / float64(m.Level) return float64(m.MaxHP+m.MaxDmg+m.Armor) / float64(m.Level)
} }
// ExpPerHP returns the experience reward per hit point (efficiency metric) // Returns the experience reward per hit point (efficiency metric)
func (m *Monster) ExpPerHP() float64 { func (m *Monster) ExpPerHP() float64 {
if m.MaxHP == 0 { if m.MaxHP == 0 {
return 0 return 0
@ -253,34 +235,10 @@ func (m *Monster) ExpPerHP() float64 {
return float64(m.MaxExp) / float64(m.MaxHP) return float64(m.MaxExp) / float64(m.MaxHP)
} }
// GoldPerHP returns the gold reward per hit point (efficiency metric) // Returns the gold reward per hit point (efficiency metric)
func (m *Monster) GoldPerHP() float64 { func (m *Monster) GoldPerHP() float64 {
if m.MaxHP == 0 { if m.MaxHP == 0 {
return 0 return 0
} }
return float64(m.MaxGold) / float64(m.MaxHP) return float64(m.MaxGold) / float64(m.MaxHP)
} }
// ToMap converts the monster to a map for efficient template rendering
func (m *Monster) ToMap() map[string]any {
return map[string]any{
"ID": m.ID,
"Name": m.Name,
"MaxHP": m.MaxHP,
"MaxDmg": m.MaxDmg,
"Armor": m.Armor,
"Level": m.Level,
"MaxExp": m.MaxExp,
"MaxGold": m.MaxGold,
"Immune": m.Immune,
// Computed values
"IsHurtImmune": m.IsHurtImmune(),
"IsSleepImmune": m.IsSleepImmune(),
"HasImmunity": m.HasImmunity(),
"ImmunityName": m.ImmunityName(),
"DifficultyRating": m.DifficultyRating(),
"ExpPerHP": m.ExpPerHP(),
"GoldPerHP": m.GoldPerHP(),
}
}

View File

@ -12,13 +12,39 @@ import (
// News represents a news post in the database // News represents a news post in the database
type News struct { type News struct {
database.BaseModel
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
Author int `db:"author" json:"author"` Author int `db:"author" json:"author"`
Posted int64 `db:"posted" json:"posted"` Posted int64 `db:"posted" json:"posted"`
Content string `db:"content" json:"content"` Content string `db:"content" json:"content"`
} }
// New creates a new News with sensible defaults func (n *News) GetTableName() string {
return "news"
}
func (n *News) GetID() int {
return n.ID
}
func (n *News) SetID(id int) {
n.ID = id
}
func (n *News) Set(field string, value any) error {
return database.Set(n, field, value)
}
func (n *News) Save() error {
return database.Save(n)
}
func (n *News) Delete() error {
return database.Delete(n)
}
// Creates a new News with sensible defaults
func New() *News { func New() *News {
return &News{ return &News{
Author: 0, // No author by default Author: 0, // No author by default
@ -29,19 +55,19 @@ func New() *News {
var newsScanner = scanner.New[News]() var newsScanner = scanner.New[News]()
// newsColumns returns the column list for news queries // Returns the column list for news queries
func newsColumns() string { func newsColumns() string {
return newsScanner.Columns() return newsScanner.Columns()
} }
// scanNews populates a News struct using the fast scanner // Populates a News struct using the fast scanner
func scanNews(stmt *sqlite.Stmt) *News { func scanNews(stmt *sqlite.Stmt) *News {
news := &News{} news := &News{}
newsScanner.Scan(stmt, news) newsScanner.Scan(stmt, news)
return news return news
} }
// Find retrieves a news post by ID // Retrieves a news post by ID
func Find(id int) (*News, error) { func Find(id int) (*News, error) {
var news *News var news *News
@ -63,7 +89,7 @@ func Find(id int) (*News, error) {
return news, nil return news, nil
} }
// All retrieves all news posts ordered by posted date (newest first) // Retrieves all news posts ordered by posted date (newest first)
func All() ([]*News, error) { func All() ([]*News, error) {
var newsPosts []*News var newsPosts []*News
@ -82,7 +108,7 @@ func All() ([]*News, error) {
return newsPosts, nil return newsPosts, nil
} }
// ByAuthor retrieves news posts by a specific author // Retrieves news posts by a specific author
func ByAuthor(authorID int) ([]*News, error) { func ByAuthor(authorID int) ([]*News, error) {
var newsPosts []*News var newsPosts []*News
@ -101,7 +127,7 @@ func ByAuthor(authorID int) ([]*News, error) {
return newsPosts, nil return newsPosts, nil
} }
// Recent retrieves the most recent news posts (limited by count) // Retrieves the most recent news posts (limited by count)
func Recent(limit int) ([]*News, error) { func Recent(limit int) ([]*News, error) {
var newsPosts []*News var newsPosts []*News
@ -120,7 +146,7 @@ func Recent(limit int) ([]*News, error) {
return newsPosts, nil return newsPosts, nil
} }
// Since retrieves news posts since a specific timestamp // Retrieves news posts since a specific timestamp
func Since(since int64) ([]*News, error) { func Since(since int64) ([]*News, error) {
var newsPosts []*News var newsPosts []*News
@ -139,7 +165,7 @@ func Since(since int64) ([]*News, error) {
return newsPosts, nil return newsPosts, nil
} }
// Between retrieves news posts between two timestamps (inclusive) // Retrieves news posts between two timestamps (inclusive)
func Between(start, end int64) ([]*News, error) { func Between(start, end int64) ([]*News, error) {
var newsPosts []*News var newsPosts []*News
@ -158,88 +184,44 @@ func Between(start, end int64) ([]*News, error) {
return newsPosts, nil return newsPosts, nil
} }
// Save updates an existing news post in the database // Saves a new news post to the database and sets the ID
func (n *News) Save() error {
if n.ID == 0 {
return fmt.Errorf("cannot save news without ID")
}
query := `UPDATE news SET author = ?, posted = ?, content = ? WHERE id = ?`
return database.Exec(query, n.Author, n.Posted, n.Content, n.ID)
}
// Insert saves a new news post to the database and sets the ID
func (n *News) Insert() error { func (n *News) Insert() error {
if n.ID != 0 { columns := `author, posted, content`
return fmt.Errorf("news already has ID %d, use Save() to update", n.ID) values := []any{n.Author, n.Posted, n.Content}
} return database.Insert(n, columns, values...)
// Use a transaction to ensure we can get the ID
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO news (author, posted, content) VALUES (?, ?, ?)`
if err := tx.Exec(query, n.Author, n.Posted, n.Content); err != nil {
return fmt.Errorf("failed to insert news: %w", err)
}
// Get the last insert ID
var id int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
id = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get insert ID: %w", err)
}
n.ID = id
return nil
})
return err
} }
// Delete removes the news post from the database // Returns the posted timestamp as a time.Time
func (n *News) Delete() error {
if n.ID == 0 {
return fmt.Errorf("cannot delete news without ID")
}
query := "DELETE FROM news WHERE id = ?"
return database.Exec(query, n.ID)
}
// PostedTime returns the posted timestamp as a time.Time
func (n *News) PostedTime() time.Time { func (n *News) PostedTime() time.Time {
return time.Unix(n.Posted, 0) return time.Unix(n.Posted, 0)
} }
// SetPostedTime sets the posted timestamp from a time.Time // Sets the posted timestamp from a time.Time
func (n *News) SetPostedTime(t time.Time) { func (n *News) SetPostedTime(t time.Time) {
n.Posted = t.Unix() n.Set("Posted", t.Unix())
} }
// IsRecent returns true if the news post was made within the last 24 hours // Returns true if the news post was made within the last 24 hours
func (n *News) IsRecent() bool { func (n *News) IsRecent() bool {
return time.Since(n.PostedTime()) < 24*time.Hour return time.Since(n.PostedTime()) < 24*time.Hour
} }
// Age returns how long ago the news post was made // Returns how long ago the news post was made
func (n *News) Age() time.Duration { func (n *News) Age() time.Duration {
return time.Since(n.PostedTime()) return time.Since(n.PostedTime())
} }
// ReadableTime converts a time.Time to a human-readable date string // Converts a time.Time to a human-readable date string
func (n *News) ReadableTime() string { func (n *News) ReadableTime() string {
return n.PostedTime().Format("Jan 2, 2006 3:04 PM") return n.PostedTime().Format("Jan 2, 2006 3:04 PM")
} }
// IsAuthor returns true if the given user ID is the author of this news post // Returns true if the given user ID is the author of this news post
func (n *News) IsAuthor(userID int) bool { func (n *News) IsAuthor(userID int) bool {
return n.Author == userID return n.Author == userID
} }
// Preview returns a truncated version of the content for previews // Returns a truncated version of the content for previews
func (n *News) Preview(maxLength int) string { func (n *News) Preview(maxLength int) string {
if len(n.Content) <= maxLength { if len(n.Content) <= maxLength {
return n.Content return n.Content
@ -252,7 +234,7 @@ func (n *News) Preview(maxLength int) string {
return n.Content[:maxLength-3] + "..." return n.Content[:maxLength-3] + "..."
} }
// WordCount returns the number of words in the content // Returns the number of words in the content
func (n *News) WordCount() int { func (n *News) WordCount() int {
if n.Content == "" { if n.Content == "" {
return 0 return 0
@ -280,22 +262,22 @@ func (n *News) WordCount() int {
return words return words
} }
// Length returns the character length of the content // Returns the character length of the content
func (n *News) Length() int { func (n *News) Length() int {
return len(n.Content) return len(n.Content)
} }
// Contains returns true if the content contains the given term (case-insensitive) // Returns true if the content contains the given term (case-insensitive)
func (n *News) Contains(term string) bool { func (n *News) Contains(term string) bool {
return strings.Contains(strings.ToLower(n.Content), strings.ToLower(term)) return strings.Contains(strings.ToLower(n.Content), strings.ToLower(term))
} }
// IsEmpty returns true if the content is empty or whitespace-only // Returns true if the content is empty or whitespace-only
func (n *News) IsEmpty() bool { func (n *News) IsEmpty() bool {
return strings.TrimSpace(n.Content) == "" return strings.TrimSpace(n.Content) == ""
} }
// Search retrieves news posts containing the search term in content // Retrieves news posts containing the search term in content
func Search(term string) ([]*News, error) { func Search(term string) ([]*News, error) {
var newsPosts []*News var newsPosts []*News
@ -314,22 +296,3 @@ func Search(term string) ([]*News, error) {
return newsPosts, nil return newsPosts, nil
} }
// ToMap converts the news to a map for efficient template rendering
func (n *News) ToMap() map[string]any {
return map[string]any{
"ID": n.ID,
"Author": n.Author,
"Posted": n.Posted,
"Content": n.Content,
// Computed values
"PostedTime": n.PostedTime(),
"IsRecent": n.IsRecent(),
"Age": n.Age(),
"ReadableTime": n.ReadableTime(),
"WordCount": n.WordCount(),
"Length": n.Length(),
"IsEmpty": n.IsEmpty(),
}
}

View File

@ -2,6 +2,8 @@ package routes
import ( import (
"dk/internal/auth" "dk/internal/auth"
"dk/internal/helpers"
"dk/internal/items"
"dk/internal/middleware" "dk/internal/middleware"
"dk/internal/router" "dk/internal/router"
"dk/internal/template/components" "dk/internal/template/components"
@ -17,6 +19,7 @@ func RegisterTownRoutes(r *router.Router) {
group.Get("/", showTown) group.Get("/", showTown)
group.Get("/inn", showInn) group.Get("/inn", showInn)
group.Get("/shop", showShop)
group.WithMiddleware(middleware.CSRF(auth.Manager)).Post("/inn", rest) group.WithMiddleware(middleware.CSRF(auth.Manager)).Post("/inn", rest)
} }
@ -58,3 +61,23 @@ func rest(ctx router.Ctx, _ []string) {
"rested": true, "rested": true,
}) })
} }
func showShop(ctx router.Ctx, _ []string) {
town := ctx.UserValue("town").(*towns.Town)
var itemlist []*items.Item
if town.ShopList != "" {
itemMap := helpers.NewOrderedMap[int, *items.Item]()
for _, id := range town.GetShopItems() {
if item, err := items.Find(id); err == nil {
itemMap.Set(id, item)
}
}
itemlist = itemMap.ToSlice()
}
components.RenderPage(ctx, town.Name+" Shop", "town/shop.html", map[string]any{
"town": town,
"itemlist": itemlist,
})
}

View File

@ -11,6 +11,8 @@ import (
// Spell represents a spell in the database // Spell represents a spell in the database
type Spell struct { type Spell struct {
database.BaseModel
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
MP int `db:"mp" json:"mp"` MP int `db:"mp" json:"mp"`
@ -18,7 +20,31 @@ type Spell struct {
Type int `db:"type" json:"type"` Type int `db:"type" json:"type"`
} }
// New creates a new Spell with sensible defaults func (s *Spell) GetTableName() string {
return "spells"
}
func (s *Spell) GetID() int {
return s.ID
}
func (s *Spell) SetID(id int) {
s.ID = id
}
func (s *Spell) Set(field string, value any) error {
return database.Set(s, field, value)
}
func (s *Spell) Save() error {
return database.Save(s)
}
func (s *Spell) Delete() error {
return database.Delete(s)
}
// Creates a new Spell with sensible defaults
func New() *Spell { func New() *Spell {
return &Spell{ return &Spell{
Name: "", Name: "",
@ -30,12 +56,12 @@ func New() *Spell {
var spellScanner = scanner.New[Spell]() var spellScanner = scanner.New[Spell]()
// spellColumns returns the column list for spell queries // Returns the column list for spell queries
func spellColumns() string { func spellColumns() string {
return spellScanner.Columns() return spellScanner.Columns()
} }
// scanSpell populates a Spell struct using the fast scanner // Populates a Spell struct using the fast scanner
func scanSpell(stmt *sqlite.Stmt) *Spell { func scanSpell(stmt *sqlite.Stmt) *Spell {
spell := &Spell{} spell := &Spell{}
spellScanner.Scan(stmt, spell) spellScanner.Scan(stmt, spell)
@ -51,7 +77,7 @@ const (
TypeDefenseBoost = 5 TypeDefenseBoost = 5
) )
// Find retrieves a spell by ID // Retrieves a spell by ID
func Find(id int) (*Spell, error) { func Find(id int) (*Spell, error) {
var spell *Spell var spell *Spell
@ -73,7 +99,7 @@ func Find(id int) (*Spell, error) {
return spell, nil return spell, nil
} }
// All retrieves all spells // Retrieves all spells
func All() ([]*Spell, error) { func All() ([]*Spell, error) {
var spells []*Spell var spells []*Spell
@ -92,7 +118,7 @@ func All() ([]*Spell, error) {
return spells, nil return spells, nil
} }
// ByType retrieves spells by type // Retrieves spells by type
func ByType(spellType int) ([]*Spell, error) { func ByType(spellType int) ([]*Spell, error) {
var spells []*Spell var spells []*Spell
@ -111,7 +137,7 @@ func ByType(spellType int) ([]*Spell, error) {
return spells, nil return spells, nil
} }
// ByMaxMP retrieves spells that cost at most the specified MP // Retrieves spells that cost at most the specified MP
func ByMaxMP(maxMP int) ([]*Spell, error) { func ByMaxMP(maxMP int) ([]*Spell, error) {
var spells []*Spell var spells []*Spell
@ -130,7 +156,7 @@ func ByMaxMP(maxMP int) ([]*Spell, error) {
return spells, nil return spells, nil
} }
// ByTypeAndMaxMP retrieves spells of a specific type that cost at most the specified MP // Retrieves spells of a specific type that cost at most the specified MP
func ByTypeAndMaxMP(spellType, maxMP int) ([]*Spell, error) { func ByTypeAndMaxMP(spellType, maxMP int) ([]*Spell, error) {
var spells []*Spell var spells []*Spell
@ -149,7 +175,7 @@ func ByTypeAndMaxMP(spellType, maxMP int) ([]*Spell, error) {
return spells, nil return spells, nil
} }
// ByName retrieves a spell by name (case-insensitive) // Retrieves a spell by name (case-insensitive)
func ByName(name string) (*Spell, error) { func ByName(name string) (*Spell, error) {
var spell *Spell var spell *Spell
@ -171,83 +197,39 @@ func ByName(name string) (*Spell, error) {
return spell, nil return spell, nil
} }
// Save updates an existing spell in the database // Saves a new spell to the database and sets the ID
func (s *Spell) Save() error {
if s.ID == 0 {
return fmt.Errorf("cannot save spell without ID")
}
query := `UPDATE spells SET name = ?, mp = ?, attribute = ?, type = ? WHERE id = ?`
return database.Exec(query, s.Name, s.MP, s.Attribute, s.Type, s.ID)
}
// Insert saves a new spell to the database and sets the ID
func (s *Spell) Insert() error { func (s *Spell) Insert() error {
if s.ID != 0 { columns := `name, mp, attribute, type`
return fmt.Errorf("spell already has ID %d, use Save() to update", s.ID) values := []any{s.Name, s.MP, s.Attribute, s.Type}
} return database.Insert(s, columns, values...)
// Use a transaction to ensure we can get the ID
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO spells (name, mp, attribute, type) VALUES (?, ?, ?, ?)`
if err := tx.Exec(query, s.Name, s.MP, s.Attribute, s.Type); err != nil {
return fmt.Errorf("failed to insert spell: %w", err)
}
// Get the last insert ID
var id int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
id = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get insert ID: %w", err)
}
s.ID = id
return nil
})
return err
} }
// Delete removes the spell from the database // Returns true if the spell is a healing spell
func (s *Spell) Delete() error {
if s.ID == 0 {
return fmt.Errorf("cannot delete spell without ID")
}
query := "DELETE FROM spells WHERE id = ?"
return database.Exec(query, s.ID)
}
// IsHealing returns true if the spell is a healing spell
func (s *Spell) IsHealing() bool { func (s *Spell) IsHealing() bool {
return s.Type == TypeHealing return s.Type == TypeHealing
} }
// IsHurt returns true if the spell is a hurt spell // Returns true if the spell is a hurt spell
func (s *Spell) IsHurt() bool { func (s *Spell) IsHurt() bool {
return s.Type == TypeHurt return s.Type == TypeHurt
} }
// IsSleep returns true if the spell is a sleep spell // Returns true if the spell is a sleep spell
func (s *Spell) IsSleep() bool { func (s *Spell) IsSleep() bool {
return s.Type == TypeSleep return s.Type == TypeSleep
} }
// IsAttackBoost returns true if the spell boosts attack // Returns true if the spell boosts attack
func (s *Spell) IsAttackBoost() bool { func (s *Spell) IsAttackBoost() bool {
return s.Type == TypeAttackBoost return s.Type == TypeAttackBoost
} }
// IsDefenseBoost returns true if the spell boosts defense // Returns true if the spell boosts defense
func (s *Spell) IsDefenseBoost() bool { func (s *Spell) IsDefenseBoost() bool {
return s.Type == TypeDefenseBoost return s.Type == TypeDefenseBoost
} }
// TypeName returns the string representation of the spell type // Returns the string representation of the spell type
func (s *Spell) TypeName() string { func (s *Spell) TypeName() string {
switch s.Type { switch s.Type {
case TypeHealing: case TypeHealing:
@ -265,12 +247,12 @@ func (s *Spell) TypeName() string {
} }
} }
// CanCast returns true if the spell can be cast with the given MP // Returns true if the spell can be cast with the given MP
func (s *Spell) CanCast(availableMP int) bool { func (s *Spell) CanCast(availableMP int) bool {
return availableMP >= s.MP return availableMP >= s.MP
} }
// Efficiency returns the attribute per MP ratio (higher is more efficient) // Returns the attribute per MP ratio (higher is more efficient)
func (s *Spell) Efficiency() float64 { func (s *Spell) Efficiency() float64 {
if s.MP == 0 { if s.MP == 0 {
return 0 return 0
@ -278,34 +260,12 @@ func (s *Spell) Efficiency() float64 {
return float64(s.Attribute) / float64(s.MP) return float64(s.Attribute) / float64(s.MP)
} }
// IsOffensive returns true if the spell is used for attacking // Returns true if the spell is used for attacking
func (s *Spell) IsOffensive() bool { func (s *Spell) IsOffensive() bool {
return s.Type == TypeHurt || s.Type == TypeSleep return s.Type == TypeHurt || s.Type == TypeSleep
} }
// IsSupport returns true if the spell is used for support/buffs // Returns true if the spell is used for support/buffs
func (s *Spell) IsSupport() bool { func (s *Spell) IsSupport() bool {
return s.Type == TypeHealing || s.Type == TypeAttackBoost || s.Type == TypeDefenseBoost return s.Type == TypeHealing || s.Type == TypeAttackBoost || s.Type == TypeDefenseBoost
} }
// ToMap converts the spell to a map for efficient template rendering
func (s *Spell) ToMap() map[string]any {
return map[string]any{
"ID": s.ID,
"Name": s.Name,
"MP": s.MP,
"Attribute": s.Attribute,
"Type": s.Type,
// Computed values
"IsHealing": s.IsHealing(),
"IsHurt": s.IsHurt(),
"IsSleep": s.IsSleep(),
"IsAttackBoost": s.IsAttackBoost(),
"IsDefenseBoost": s.IsDefenseBoost(),
"TypeName": s.TypeName(),
"Efficiency": s.Efficiency(),
"IsOffensive": s.IsOffensive(),
"IsSupport": s.IsSupport(),
}
}

View File

@ -3,9 +3,10 @@ package towns
import ( import (
"fmt" "fmt"
"math" "math"
"strings" "slices"
"dk/internal/database" "dk/internal/database"
"dk/internal/helpers"
"dk/internal/helpers/scanner" "dk/internal/helpers/scanner"
"zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite"
@ -13,6 +14,8 @@ import (
// Town represents a town in the database // Town represents a town in the database
type Town struct { type Town struct {
database.BaseModel
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
X int `db:"x" json:"x"` X int `db:"x" json:"x"`
@ -23,11 +26,35 @@ type Town struct {
ShopList string `db:"shop_list" json:"shop_list"` ShopList string `db:"shop_list" json:"shop_list"`
} }
// New creates a new Town with sensible defaults func (t *Town) GetTableName() string {
return "towns"
}
func (t *Town) GetID() int {
return t.ID
}
func (t *Town) SetID(id int) {
t.ID = id
}
func (t *Town) Set(field string, value any) error {
return database.Set(t, field, value)
}
func (t *Town) Save() error {
return database.Save(t)
}
func (t *Town) Delete() error {
return database.Delete(t)
}
// 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, // Default coordinates
Y: 0, Y: 0,
InnCost: 50, // Default inn cost InnCost: 50, // Default inn cost
MapCost: 100, // Default map cost MapCost: 100, // Default map cost
@ -38,19 +65,19 @@ func New() *Town {
var townScanner = scanner.New[Town]() var townScanner = scanner.New[Town]()
// townColumns returns the column list for town queries // Returns the column list for town queries
func townColumns() string { func townColumns() string {
return townScanner.Columns() return townScanner.Columns()
} }
// scanTown populates a Town struct using the fast scanner // Populates a Town struct using the fast scanner
func scanTown(stmt *sqlite.Stmt) *Town { func scanTown(stmt *sqlite.Stmt) *Town {
town := &Town{} town := &Town{}
townScanner.Scan(stmt, town) townScanner.Scan(stmt, town)
return town return town
} }
// Find retrieves a town by ID // Retrieves a town by ID
func Find(id int) (*Town, error) { func Find(id int) (*Town, error) {
var town *Town var town *Town
@ -72,7 +99,7 @@ func Find(id int) (*Town, error) {
return town, nil return town, nil
} }
// All retrieves all towns // Retrieves all towns
func All() ([]*Town, error) { func All() ([]*Town, error) {
var towns []*Town var towns []*Town
@ -91,7 +118,7 @@ func All() ([]*Town, error) {
return towns, nil return towns, nil
} }
// ByName retrieves a town by name (case-insensitive) // Retrieves a town by name (case-insensitive)
func ByName(name string) (*Town, error) { func ByName(name string) (*Town, error) {
var town *Town var town *Town
@ -113,7 +140,7 @@ func ByName(name string) (*Town, error) {
return town, nil return town, nil
} }
// ByMaxInnCost retrieves towns with inn cost at most the specified amount // Retrieves towns with inn cost at most the specified amount
func ByMaxInnCost(maxCost int) ([]*Town, error) { func ByMaxInnCost(maxCost int) ([]*Town, error) {
var towns []*Town var towns []*Town
@ -132,7 +159,7 @@ func ByMaxInnCost(maxCost int) ([]*Town, error) {
return towns, nil return towns, nil
} }
// ByMaxTPCost retrieves towns with teleport cost at most the specified amount // Retrieves towns with teleport cost at most the specified amount
func ByMaxTPCost(maxCost int) ([]*Town, error) { func ByMaxTPCost(maxCost int) ([]*Town, error) {
var towns []*Town var towns []*Town
@ -151,7 +178,7 @@ func ByMaxTPCost(maxCost int) ([]*Town, error) {
return towns, nil return towns, nil
} }
// ByCoords retrieves a town by its x, y coordinates // Retrieves a town by its x, y coordinates
func ByCoords(x, y int) (*Town, error) { func ByCoords(x, y int) (*Town, error) {
var town *Town var town *Town
@ -169,7 +196,7 @@ func ByCoords(x, y int) (*Town, error) {
return town, nil return town, nil
} }
// ByDistance retrieves towns within a certain distance from a point // Retrieves towns within a certain distance from a point
func ByDistance(fromX, fromY, maxDistance int) ([]*Town, error) { func ByDistance(fromX, fromY, maxDistance int) ([]*Town, error) {
var towns []*Town var towns []*Town
@ -192,145 +219,72 @@ func ByDistance(fromX, fromY, maxDistance int) ([]*Town, error) {
return towns, nil return towns, nil
} }
// Save updates an existing town in the database // Saves a new town to the database and sets the ID
func (t *Town) Save() error {
if t.ID == 0 {
return fmt.Errorf("cannot save town without ID")
}
query := `UPDATE towns SET name = ?, x = ?, y = ?, inn_cost = ?, map_cost = ?, tp_cost = ?, shop_list = ? WHERE id = ?`
return database.Exec(query, t.Name, t.X, t.Y, t.InnCost, t.MapCost, t.TPCost, t.ShopList, t.ID)
}
// Insert saves a new town to the database and sets the ID
func (t *Town) Insert() error { func (t *Town) Insert() error {
if t.ID != 0 { columns := `name, x, y, inn_cost, map_cost, tp_cost, shop_list`
return fmt.Errorf("town already has ID %d, use Save() to update", t.ID) values := []any{t.Name, t.X, t.Y, t.InnCost, t.MapCost, t.TPCost, t.ShopList}
} return database.Insert(t, columns, values...)
// Use a transaction to ensure we can get the ID
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO towns (name, x, y, inn_cost, map_cost, tp_cost, shop_list) VALUES (?, ?, ?, ?, ?, ?, ?)`
if err := tx.Exec(query, t.Name, t.X, t.Y, t.InnCost, t.MapCost, t.TPCost, t.ShopList); err != nil {
return fmt.Errorf("failed to insert town: %w", err)
}
// Get the last insert ID
var id int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
id = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get insert ID: %w", err)
}
t.ID = id
return nil
})
return err
} }
// Delete removes the town from the database // Returns the shop items as a slice of item IDs
func (t *Town) Delete() error { func (t *Town) GetShopItems() []int {
if t.ID == 0 { return helpers.StringToInts(t.ShopList)
return fmt.Errorf("cannot delete town without ID")
}
query := "DELETE FROM towns WHERE id = ?"
return database.Exec(query, t.ID)
} }
// GetShopItems returns the shop items as a slice of item IDs // Sets the shop items from a slice of item IDs
func (t *Town) GetShopItems() []string { func (t *Town) SetShopItems(items []int) {
if t.ShopList == "" { t.Set("ShopList", helpers.IntsToString(items))
return []string{}
}
return strings.Split(t.ShopList, ",")
} }
// SetShopItems sets the shop items from a slice of item IDs // Checks if the town's shop sells a specific item ID
func (t *Town) SetShopItems(items []string) { func (t *Town) HasShopItem(itemID int) bool {
t.ShopList = strings.Join(items, ",") return slices.Contains(t.GetShopItems(), itemID)
} }
// HasShopItem checks if the town's shop sells a specific item ID // Calculates the squared distance from this town to given coordinates
func (t *Town) HasShopItem(itemID string) bool {
items := t.GetShopItems()
for _, item := range items {
if strings.TrimSpace(item) == itemID {
return true
}
}
return false
}
// DistanceFrom calculates the squared distance from this town to given coordinates
func (t *Town) DistanceFromSquared(x, y int) float64 { func (t *Town) DistanceFromSquared(x, y int) float64 {
dx := float64(t.X - x) dx := float64(t.X - x)
dy := float64(t.Y - y) dy := float64(t.Y - y)
return dx*dx + dy*dy // Return squared distance for performance return dx*dx + dy*dy // Return squared distance for performance
} }
// DistanceFrom calculates the actual distance from this town to given coordinates // Calculates the actual distance from this town to given coordinates
func (t *Town) DistanceFrom(x, y int) float64 { func (t *Town) DistanceFrom(x, y int) float64 {
return math.Sqrt(t.DistanceFromSquared(x, y)) return math.Sqrt(t.DistanceFromSquared(x, y))
} }
// IsStartingTown returns true if this is the starting town (Midworld) // Returns true if this is the starting town (Midworld)
func (t *Town) IsStartingTown() bool { func (t *Town) IsStartingTown() bool {
return t.X == 0 && t.Y == 0 return t.X == 0 && t.Y == 0
} }
// CanAffordInn returns true if the player can afford the inn // Returns true if the player can afford the inn
func (t *Town) CanAffordInn(gold int) bool { func (t *Town) CanAffordInn(gold int) bool {
return gold >= t.InnCost return gold >= t.InnCost
} }
// CanAffordMap returns true if the player can afford to buy the map // Returns true if the player can afford to buy the map
func (t *Town) CanAffordMap(gold int) bool { func (t *Town) CanAffordMap(gold int) bool {
return gold >= t.MapCost return gold >= t.MapCost
} }
// CanAffordTeleport returns true if the player can afford to teleport here // Returns true if the player can afford to teleport here
func (t *Town) CanAffordTeleport(gold int) bool { func (t *Town) CanAffordTeleport(gold int) bool {
return gold >= t.TPCost return gold >= t.TPCost
} }
// HasShop returns true if the town has a shop with items // Returns true if the town has a shop with items
func (t *Town) HasShop() bool { func (t *Town) HasShop() bool {
return len(t.GetShopItems()) > 0 return len(t.GetShopItems()) > 0
} }
// GetPosition returns the town's coordinates // Returns the town's coordinates
func (t *Town) GetPosition() (int, int) { func (t *Town) GetPosition() (int, int) {
return t.X, t.Y return t.X, t.Y
} }
// SetPosition sets the town's coordinates // Sets the town's coordinates
func (t *Town) SetPosition(x, y int) { func (t *Town) SetPosition(x, y int) {
t.X = x t.Set("X", x)
t.Y = y t.Set("Y", y)
}
// ToMap converts the town to a map for efficient template rendering
func (t *Town) ToMap() map[string]any {
return map[string]any{
"ID": t.ID,
"Name": t.Name,
"X": t.X,
"Y": t.Y,
"InnCost": t.InnCost,
"MapCost": t.MapCost,
"TPCost": t.TPCost,
"ShopList": t.ShopList,
// Computed values
"ShopItems": t.GetShopItems(),
"HasShop": t.HasShop(),
"IsStartingTown": t.IsStartingTown(),
"Position": map[string]int{"X": t.X, "Y": t.Y},
}
} }

View File

@ -339,71 +339,3 @@ func (u *User) SetPosition(x, y int) {
u.Set("X", x) u.Set("X", x)
u.Set("Y", y) u.Set("Y", y)
} }
func (u *User) ToMap() map[string]any {
return map[string]any{
"ID": u.ID,
"Username": u.Username,
"Email": u.Email,
"Verified": u.Verified,
"Token": u.Token,
"Registered": u.Registered,
"LastOnline": u.LastOnline,
"Auth": u.Auth,
"X": u.X,
"Y": u.Y,
"ClassID": u.ClassID,
"Currently": u.Currently,
"Fighting": u.Fighting,
"MonsterID": u.MonsterID,
"MonsterHP": u.MonsterHP,
"MonsterSleep": u.MonsterSleep,
"MonsterImmune": u.MonsterImmune,
"UberDamage": u.UberDamage,
"UberDefense": u.UberDefense,
"HP": u.HP,
"MP": u.MP,
"TP": u.TP,
"MaxHP": u.MaxHP,
"MaxMP": u.MaxMP,
"MaxTP": u.MaxTP,
"Level": u.Level,
"Gold": u.Gold,
"Exp": u.Exp,
"GoldBonus": u.GoldBonus,
"ExpBonus": u.ExpBonus,
"Strength": u.Strength,
"Dexterity": u.Dexterity,
"Attack": u.Attack,
"Defense": u.Defense,
"WeaponID": u.WeaponID,
"ArmorID": u.ArmorID,
"ShieldID": u.ShieldID,
"Slot1ID": u.Slot1ID,
"Slot2ID": u.Slot2ID,
"Slot3ID": u.Slot3ID,
"WeaponName": u.WeaponName,
"ArmorName": u.ArmorName,
"ShieldName": u.ShieldName,
"Slot1Name": u.Slot1Name,
"Slot2Name": u.Slot2Name,
"Slot3Name": u.Slot3Name,
"DropCode": u.DropCode,
"Spells": u.Spells,
"Towns": u.Towns,
// Computed values
"IsVerified": u.IsVerified(),
"IsAdmin": u.IsAdmin(),
"IsModerator": u.IsModerator(),
"IsFighting": u.IsFighting(),
"IsAlive": u.IsAlive(),
"RegisteredTime": u.RegisteredTime(),
"LastOnlineTime": u.LastOnlineTime(),
"Equipment": u.GetEquipment(),
"Stats": u.GetStats(),
"Position": map[string]int{"X": u.X, "Y": u.Y},
"SpellIDs": u.GetSpellIDs(),
"TownIDs": u.GetTownIDs(),
}
}

View File

@ -26,8 +26,12 @@
<div class="title"><img src="/assets/images/button_towns.gif" alt="Towns" title="Towns"></div> <div class="title"><img src="/assets/images/button_towns.gif" alt="Towns" title="Towns"></div>
{if #_towns > 0} {if #_towns > 0}
<i>Teleport to:</i> <i>Teleport to:</i>
{for map in _towns} {for id,name in _towns}
<a href="#">{map.name}</a> {if town != nil and name == town.Name}
<span>{name} <i>(here)</i></span>
{else}
<a href="#">{name}</a>
{/if}
{/for} {/for}
{else} {else}
<i>No town maps</i> <i>No town maps</i>

View File

@ -65,8 +65,8 @@
<section> <section>
<div class="title"><img src="/assets/images/button_fastspells.gif" alt="Fast Spells" title="Fast Spells"></div> <div class="title"><img src="/assets/images/button_fastspells.gif" alt="Fast Spells" title="Fast Spells"></div>
{if #_spells > 0} {if #_spells > 0}
{for spell in _spells} {for id,name in _spells}
<a href="#">{spell.name}</a> <a href="#">{name}</a>
{/for} {/for}
{else} {else}
<i>No known healing spells</i> <i>No known healing spells</i>

31
templates/town/shop.html Normal file
View File

@ -0,0 +1,31 @@
{include "layout.html"}
{block "content"}
<div class="town shop">
<section>
<div class="title"><h3>{town.Name} Shop</h3></div>
<p>Buying weapons will increase your Attack. Buying armor and shields will increase your Defense.</p>
<p>Click an item name to purchase it.</p>
<p>The following items are available at this town:</p>
</section>
<section>
<table class="item-shop">
{for item in itemlist}
<tr>
<td>
{if item.Type == 1}
<img src="images/icon_weapon.gif" alt="Weapon" title="Weapon">
{/if}
</td>
<td>
{item.Name}
</td>
</tr>
{/for}
</table>
<p>If you've changed your mind, you may also return back to <a href="/town">town</a>.</p>
</section>
</div>
{/block}