Compare commits

..

2 Commits

33 changed files with 1013 additions and 4738 deletions

2
go.mod
View File

@ -4,6 +4,7 @@ go 1.24.6
require ( require (
github.com/valyala/fasthttp v1.64.0 github.com/valyala/fasthttp v1.64.0
golang.org/x/crypto v0.41.0
zombiezen.com/go/sqlite v1.4.2 zombiezen.com/go/sqlite v1.4.2
) )
@ -16,7 +17,6 @@ require (
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.35.0 // indirect
modernc.org/libc v1.65.7 // indirect modernc.org/libc v1.65.7 // indirect

5
go.sum
View File

@ -29,13 +29,10 @@ golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=

View File

@ -2,8 +2,8 @@ package auth
import ( import (
"dk/internal/cookies" "dk/internal/cookies"
"dk/internal/helpers"
"dk/internal/session" "dk/internal/session"
"dk/internal/utils"
"time" "time"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
@ -18,7 +18,7 @@ func SetSessionCookie(ctx *fasthttp.RequestCtx, sessionID string) {
Path: "/", Path: "/",
Expires: time.Now().Add(session.DefaultExpiration), Expires: time.Now().Add(session.DefaultExpiration),
HTTPOnly: true, HTTPOnly: true,
Secure: utils.IsHTTPS(ctx), Secure: helpers.IsHTTPS(ctx),
SameSite: "lax", SameSite: "lax",
}) })
} }
@ -29,4 +29,4 @@ func GetSessionCookie(ctx *fasthttp.RequestCtx) string {
func DeleteSessionCookie(ctx *fasthttp.RequestCtx) { func DeleteSessionCookie(ctx *fasthttp.RequestCtx) {
cookies.DeleteCookie(ctx, SessionCookieName) cookies.DeleteCookie(ctx, SessionCookieName)
} }

View File

@ -6,28 +6,50 @@ import (
"time" "time"
"dk/internal/database" "dk/internal/database"
"dk/internal/helpers/scanner"
"zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite"
) )
// Babble represents a global chat message in the database // Babble represents a global chat message in the database
type Babble struct { type Babble struct {
ID int `json:"id"` ID int `db:"id" json:"id"`
Posted int64 `json:"posted"` Posted int64 `db:"posted" json:"posted"`
Author string `json:"author"` Author string `db:"author" json:"author"`
Babble string `json:"babble"` Babble string `db:"babble" json:"babble"`
}
// New creates a new Babble with sensible defaults
func New() *Babble {
return &Babble{
Posted: time.Now().Unix(),
Author: "",
Babble: "",
}
}
var babbleScanner = scanner.New[Babble]()
// babbleColumns returns the column list for babble queries
func babbleColumns() string {
return babbleScanner.Columns()
}
// scanBabble populates a Babble struct using the fast scanner
func scanBabble(stmt *sqlite.Stmt) *Babble {
babble := &Babble{}
babbleScanner.Scan(stmt, babble)
return babble
} }
// Find retrieves a babble message by ID // Find retrieves a babble message by ID
func Find(id int) (*Babble, error) { func Find(id int) (*Babble, error) {
babble := &Babble{} var babble *Babble
query := `SELECT ` + babbleColumns() + ` FROM babble WHERE id = ?`
query := "SELECT id, posted, author, babble FROM babble WHERE id = ?"
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
babble.ID = stmt.ColumnInt(0) babble = scanBabble(stmt)
babble.Posted = stmt.ColumnInt64(1)
babble.Author = stmt.ColumnText(2)
babble.Babble = stmt.ColumnText(3)
return nil return nil
}, id) }, id)
@ -35,7 +57,7 @@ func Find(id int) (*Babble, error) {
return nil, fmt.Errorf("failed to find babble: %w", err) return nil, fmt.Errorf("failed to find babble: %w", err)
} }
if babble.ID == 0 { if babble == nil {
return nil, fmt.Errorf("babble with ID %d not found", id) return nil, fmt.Errorf("babble with ID %d not found", id)
} }
@ -46,14 +68,10 @@ func Find(id int) (*Babble, error) {
func All() ([]*Babble, error) { func All() ([]*Babble, error) {
var babbles []*Babble var babbles []*Babble
query := "SELECT id, posted, author, babble FROM babble ORDER BY posted DESC, id DESC" query := `SELECT ` + babbleColumns() + ` FROM babble ORDER BY posted DESC, id DESC`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
babble := &Babble{ babble := scanBabble(stmt)
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
Author: stmt.ColumnText(2),
Babble: stmt.ColumnText(3),
}
babbles = append(babbles, babble) babbles = append(babbles, babble)
return nil return nil
}) })
@ -69,14 +87,10 @@ func All() ([]*Babble, error) {
func ByAuthor(author string) ([]*Babble, error) { func ByAuthor(author string) ([]*Babble, error) {
var babbles []*Babble var babbles []*Babble
query := "SELECT id, posted, author, babble FROM babble WHERE LOWER(author) = LOWER(?) ORDER BY posted DESC, id DESC" query := `SELECT ` + babbleColumns() + ` FROM babble WHERE LOWER(author) = LOWER(?) ORDER BY posted DESC, id DESC`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
babble := &Babble{ babble := scanBabble(stmt)
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
Author: stmt.ColumnText(2),
Babble: stmt.ColumnText(3),
}
babbles = append(babbles, babble) babbles = append(babbles, babble)
return nil return nil
}, author) }, author)
@ -92,14 +106,10 @@ func ByAuthor(author string) ([]*Babble, error) {
func Recent(limit int) ([]*Babble, error) { func Recent(limit int) ([]*Babble, error) {
var babbles []*Babble var babbles []*Babble
query := "SELECT id, posted, author, babble FROM babble ORDER BY posted DESC, id DESC LIMIT ?" query := `SELECT ` + babbleColumns() + ` FROM babble ORDER BY posted DESC, id DESC LIMIT ?`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
babble := &Babble{ babble := scanBabble(stmt)
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
Author: stmt.ColumnText(2),
Babble: stmt.ColumnText(3),
}
babbles = append(babbles, babble) babbles = append(babbles, babble)
return nil return nil
}, limit) }, limit)
@ -115,14 +125,10 @@ func Recent(limit int) ([]*Babble, error) {
func Since(since int64) ([]*Babble, error) { func Since(since int64) ([]*Babble, error) {
var babbles []*Babble var babbles []*Babble
query := "SELECT id, posted, author, babble FROM babble WHERE posted >= ? ORDER BY posted DESC, id DESC" query := `SELECT ` + babbleColumns() + ` FROM babble WHERE posted >= ? ORDER BY posted DESC, id DESC`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
babble := &Babble{ babble := scanBabble(stmt)
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
Author: stmt.ColumnText(2),
Babble: stmt.ColumnText(3),
}
babbles = append(babbles, babble) babbles = append(babbles, babble)
return nil return nil
}, since) }, since)
@ -138,14 +144,10 @@ func Since(since int64) ([]*Babble, error) {
func Between(start, end int64) ([]*Babble, error) { func Between(start, end int64) ([]*Babble, error) {
var babbles []*Babble var babbles []*Babble
query := "SELECT id, posted, author, babble FROM babble WHERE posted >= ? AND posted <= ? ORDER BY posted DESC, id DESC" query := `SELECT ` + babbleColumns() + ` FROM babble WHERE posted >= ? AND posted <= ? ORDER BY posted DESC, id DESC`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
babble := &Babble{ babble := scanBabble(stmt)
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
Author: stmt.ColumnText(2),
Babble: stmt.ColumnText(3),
}
babbles = append(babbles, babble) babbles = append(babbles, babble)
return nil return nil
}, start, end) }, start, end)
@ -161,16 +163,11 @@ func Between(start, end int64) ([]*Babble, error) {
func Search(term string) ([]*Babble, error) { func Search(term string) ([]*Babble, error) {
var babbles []*Babble var babbles []*Babble
query := "SELECT id, posted, author, babble FROM babble WHERE LOWER(babble) LIKE LOWER(?) ORDER BY posted DESC, id DESC" query := `SELECT ` + babbleColumns() + ` FROM babble WHERE LOWER(babble) LIKE LOWER(?) ORDER BY posted DESC, id DESC`
searchTerm := "%" + term + "%" searchTerm := "%" + term + "%"
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
babble := &Babble{ babble := scanBabble(stmt)
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
Author: stmt.ColumnText(2),
Babble: stmt.ColumnText(3),
}
babbles = append(babbles, babble) babbles = append(babbles, babble)
return nil return nil
}, searchTerm) }, searchTerm)
@ -186,14 +183,10 @@ func Search(term string) ([]*Babble, error) {
func RecentByAuthor(author string, limit int) ([]*Babble, error) { func RecentByAuthor(author string, limit int) ([]*Babble, error) {
var babbles []*Babble var babbles []*Babble
query := "SELECT id, posted, author, babble FROM babble WHERE LOWER(author) = LOWER(?) ORDER BY posted DESC, id DESC LIMIT ?" query := `SELECT ` + babbleColumns() + ` FROM babble WHERE LOWER(author) = LOWER(?) ORDER BY posted DESC, id DESC LIMIT ?`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
babble := &Babble{ babble := scanBabble(stmt)
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
Author: stmt.ColumnText(2),
Babble: stmt.ColumnText(3),
}
babbles = append(babbles, babble) babbles = append(babbles, babble)
return nil return nil
}, author, limit) }, author, limit)
@ -215,6 +208,37 @@ func (b *Babble) Save() error {
return database.Exec(query, b.Posted, b.Author, b.Babble, b.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 {
if b.ID != 0 {
return fmt.Errorf("babble already has ID %d, use Save() to update", b.ID)
}
// 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 // Delete removes the babble message from the database
func (b *Babble) Delete() error { func (b *Babble) Delete() error {
if b.ID == 0 { if b.ID == 0 {
@ -338,3 +362,22 @@ 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

@ -1,87 +0,0 @@
package babble
import (
"fmt"
"time"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating babble messages
type Builder struct {
babble *Babble
}
// NewBuilder creates a new babble builder
func NewBuilder() *Builder {
return &Builder{
babble: &Babble{
Posted: time.Now().Unix(), // Default to current time
},
}
}
// WithAuthor sets the author username
func (b *Builder) WithAuthor(author string) *Builder {
b.babble.Author = author
return b
}
// WithBabble sets the message content
func (b *Builder) WithBabble(message string) *Builder {
b.babble.Babble = message
return b
}
// WithMessage is an alias for WithBabble for more intuitive usage
func (b *Builder) WithMessage(message string) *Builder {
return b.WithBabble(message)
}
// WithPosted sets the posted timestamp
func (b *Builder) WithPosted(posted int64) *Builder {
b.babble.Posted = posted
return b
}
// WithPostedTime sets the posted timestamp from a time.Time
func (b *Builder) WithPostedTime(t time.Time) *Builder {
b.babble.Posted = t.Unix()
return b
}
// Create saves the babble message to the database and returns the created babble with ID
func (b *Builder) Create() (*Babble, error) {
// Use a transaction to ensure we can get the ID
var babble *Babble
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO babble (posted, author, babble)
VALUES (?, ?, ?)`
if err := tx.Exec(query, b.babble.Posted, b.babble.Author, b.babble.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.babble.ID = id
babble = b.babble
return nil
})
if err != nil {
return nil, err
}
return babble, nil
}

View File

@ -1,514 +0,0 @@
/*
Package babble is the active record implementation for global chat messages in the game.
Babble represents the global chat system where players can communicate with each other in real-time. The package provides comprehensive chat message management with features like mentions, search, time-based queries, and moderation utilities.
# Basic Usage
To retrieve a babble message by ID:
message, err := babble.Find(db, 1)
if err != nil {
log.Fatal(err)
}
fmt.Printf("<%s> %s\n", message.Author, message.Babble)
To get all babble messages:
allMessages, err := babble.All(db)
if err != nil {
log.Fatal(err)
}
for _, message := range allMessages {
fmt.Printf("[%s] <%s> %s\n",
message.PostedTime().Format("15:04"),
message.Author,
message.Babble)
}
To get recent chat messages:
recentChat, err := babble.Recent(db, 50)
if err != nil {
log.Fatal(err)
}
To filter messages by author:
userMessages, err := babble.ByAuthor(db, "PlayerName")
if err != nil {
log.Fatal(err)
}
# Creating Messages with Builder Pattern
The package provides a fluent builder interface for creating new chat messages:
message, err := babble.NewBuilder(db).
WithAuthor("PlayerName").
WithBabble("Hello everyone! Ready for some adventure?").
WithPostedTime(time.Now()).
Create()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Posted message with ID: %d\n", message.ID)
The builder automatically sets the current time if no posted time is specified:
message, err := babble.NewBuilder(db).
WithAuthor("Admin").
WithMessage("Server restart in 5 minutes!").
Create() // Uses current timestamp
You can use either `WithBabble()` or `WithMessage()` - they are aliases for the same functionality.
# Updating Messages
Chat messages can be modified and saved back to the database:
message, _ := babble.Find(db, 1)
message.Babble = "[EDITED] Original message was inappropriate"
err := message.Save()
if err != nil {
log.Fatal(err)
}
# Deleting Messages
Messages can be removed from the database (for moderation):
message, _ := babble.Find(db, 1)
err := message.Delete()
if err != nil {
log.Fatal(err)
}
# Database Schema
The babble table has the following structure:
CREATE TABLE babble (
id INTEGER PRIMARY KEY AUTOINCREMENT,
posted INTEGER NOT NULL DEFAULT (unixepoch()),
author TEXT NOT NULL DEFAULT '',
babble TEXT NOT NULL DEFAULT ''
)
Where:
- id: Unique identifier for the chat message
- posted: Unix timestamp when the message was posted
- author: Username of the player who posted the message
- babble: The text content of the chat message
# Time-Based Queries
## Recent Messages
Get the most recent chat messages:
// Get 100 most recent messages for chat display
chatHistory, err := babble.Recent(db, 100)
if err != nil {
log.Fatal(err)
}
fmt.Println("=== Recent Chat ===")
for _, msg := range chatHistory {
age := msg.Age()
timeStr := ""
if age < time.Minute {
timeStr = "just now"
} else if age < time.Hour {
timeStr = fmt.Sprintf("%dm ago", int(age.Minutes()))
} else {
timeStr = msg.PostedTime().Format("15:04")
}
fmt.Printf("[%s] <%s> %s\n", timeStr, msg.Author, msg.Babble)
}
## Messages Since Timestamp
Get messages posted since a specific time:
// Get messages since user's last login
lastLogin := getUserLastLogin(userID)
newMessages, err := babble.Since(db, lastLogin)
if err != nil {
log.Fatal(err)
}
if len(newMessages) > 0 {
fmt.Printf("You missed %d messages while you were away\n", len(newMessages))
}
## Messages Between Timestamps
Get messages within a time range:
// Get today's chat history
startOfDay := time.Now().Truncate(24 * time.Hour).Unix()
endOfDay := time.Now().Unix()
todaysChat, err := babble.Between(db, startOfDay, endOfDay)
if err != nil {
log.Fatal(err)
}
# Search and Filtering
## Text Search
Search for messages containing specific terms:
// Search for messages about "boss fight"
bossMessages, err := babble.Search(db, "boss fight")
if err != nil {
log.Fatal(err)
}
for _, msg := range bossMessages {
fmt.Printf("<%s> %s\n", msg.Author, msg.Preview(60))
}
Search is case-insensitive and matches partial words.
## Author-Based Queries
Get messages from specific players:
// Get recent messages from a player
playerRecent, err := babble.RecentByAuthor(db, "PlayerName", 10)
if err != nil {
log.Fatal(err)
}
// Get all messages from a player (for moderation)
allPlayerMessages, err := babble.ByAuthor(db, "ReportedPlayer")
if err != nil {
log.Fatal(err)
}
All author searches are case-insensitive.
# Mention System
## Finding Mentions
The package includes a comprehensive mention system using @username syntax:
message, _ := babble.Find(db, someID)
// Get all mentioned usernames
mentions := message.GetMentions()
for _, username := range mentions {
fmt.Printf("Message mentions: @%s\n", username)
}
// Check if specific user is mentioned
if message.HasMention("PlayerName") {
fmt.Println("You were mentioned in this message!")
}
## Mention Parsing
The mention system handles various formats:
- `@username` - Basic mention
- `@username!` - With punctuation
- `@username,` - In lists
- `@username?` - In questions
Mentions are extracted without the punctuation and are case-insensitive.
## Notification Integration
Use mentions for player notifications:
// Process new messages for mentions
recentMessages, _ := babble.Recent(db, 50)
for _, msg := range recentMessages {
mentions := msg.GetMentions()
for _, mentionedUser := range mentions {
// Send notification to mentioned user
notifyUser(mentionedUser, fmt.Sprintf("%s mentioned you: %s",
msg.Author, msg.Preview(50)))
}
}
# Message Analysis
## Content Analysis
Analyze message content for moderation or statistics:
message, _ := babble.Find(db, someID)
// Basic content metrics
fmt.Printf("Length: %d characters\n", message.Length())
fmt.Printf("Words: %d\n", message.WordCount())
// Content checks
if message.IsEmpty() {
fmt.Println("Empty message detected")
}
if message.IsLongMessage(200) {
fmt.Println("Very long message - possible spam")
}
// Search within message
if message.Contains("inappropriate_term") {
fmt.Println("Message flagged for review")
}
## Time Analysis
Analyze posting patterns:
message, _ := babble.Find(db, someID)
age := message.Age()
fmt.Printf("Message posted %v ago\n", age)
if message.IsRecent() {
fmt.Println("This is a recent message (within 1 hour)")
}
// Format for display
postedTime := message.PostedTime()
if age < 24*time.Hour {
fmt.Printf("Posted at %s\n", postedTime.Format("15:04"))
} else {
fmt.Printf("Posted on %s\n", postedTime.Format("Jan 2 15:04"))
}
# Chat Display Patterns
## Live Chat Feed
Display real-time chat messages:
func displayChatFeed(db *database.DB) {
messages, _ := babble.Recent(db, 50)
fmt.Println("=== Global Chat ===")
for i := len(messages) - 1; i >= 0; i-- { // Reverse for chronological order
msg := messages[i]
// Format timestamp
age := msg.Age()
var timeStr string
if age < time.Minute {
timeStr = "now"
} else if age < time.Hour {
timeStr = fmt.Sprintf("%dm", int(age.Minutes()))
} else {
timeStr = msg.PostedTime().Format("15:04")
}
// Handle mentions highlighting
content := msg.Babble
if msg.HasMention(currentUser) {
content = fmt.Sprintf("🔔 %s", content) // Highlight mentions
}
fmt.Printf("[%s] <%s> %s\n", timeStr, msg.Author, content)
}
}
## Chat History Browser
Browse historical messages:
func browseChatHistory(db *database.DB, page int, pageSize int) {
offset := page * pageSize
// Get paginated results (implement with LIMIT/OFFSET)
allMessages, _ := babble.All(db)
start := offset
end := offset + pageSize
if end > len(allMessages) {
end = len(allMessages)
}
if start >= len(allMessages) {
fmt.Println("No more messages")
return
}
pageMessages := allMessages[start:end]
fmt.Printf("=== Chat History (Page %d) ===\n", page+1)
for _, msg := range pageMessages {
fmt.Printf("%s <%s> %s\n",
msg.PostedTime().Format("Jan 2 15:04"),
msg.Author,
msg.Babble)
}
}
# Moderation Features
## Content Moderation
Tools for chat moderation:
// Flag inappropriate messages
func moderateMessage(db *database.DB, messageID int) {
msg, err := babble.Find(db, messageID)
if err != nil {
return
}
// Check for spam (very short or very long)
if msg.WordCount() < 2 {
fmt.Printf("Possible spam: %s\n", msg.Preview(30))
}
if msg.IsLongMessage(500) {
fmt.Printf("Very long message from %s\n", msg.Author)
}
// Check for excessive mentions
mentions := msg.GetMentions()
if len(mentions) > 5 {
fmt.Printf("Message with %d mentions from %s\n", len(mentions), msg.Author)
}
}
## User Activity Analysis
Analyze user posting patterns:
// Check user activity
func analyzeUserActivity(db *database.DB, username string) {
// Recent activity
recentMessages, _ := babble.RecentByAuthor(db, username, 10)
fmt.Printf("User %s recent activity:\n", username)
fmt.Printf("- Recent messages: %d\n", len(recentMessages))
if len(recentMessages) > 0 {
totalWords := 0
for _, msg := range recentMessages {
totalWords += msg.WordCount()
}
avgWords := totalWords / len(recentMessages)
fmt.Printf("- Average words per message: %d\n", avgWords)
latest := recentMessages[0]
fmt.Printf("- Last message: %s (%s ago)\n",
latest.Preview(40), latest.Age())
}
// Check for mention patterns
allUserMessages, _ := babble.ByAuthor(db, username)
mentionCount := 0
for _, msg := range allUserMessages {
mentionCount += len(msg.GetMentions())
}
if len(allUserMessages) > 0 {
avgMentions := float64(mentionCount) / float64(len(allUserMessages))
fmt.Printf("- Average mentions per message: %.2f\n", avgMentions)
}
}
# Performance Considerations
## Efficient Queries
All time-based queries are optimized:
// Recent messages are efficiently ordered
recent, _ := babble.Recent(db, 100) // Uses LIMIT for efficiency
// Time-based queries use indexed timestamp
since, _ := babble.Since(db, timestamp) // Efficient with proper index
// Author queries support case-insensitive search
authorMessages, _ := babble.ByAuthor(db, "username") // Uses LOWER() function
## Memory Management
For large chat histories, process in batches:
// Process messages in batches
func processAllMessages(db *database.DB, batchSize int) {
allMessages, _ := babble.All(db)
for i := 0; i < len(allMessages); i += batchSize {
end := i + batchSize
if end > len(allMessages) {
end = len(allMessages)
}
batch := allMessages[i:end]
processBatch(batch)
}
}
# Integration Examples
## Real-Time Chat
Implement live chat updates:
func pollForNewMessages(db *database.DB, lastMessageID int) []*babble.Babble {
// Get messages newer than last seen
allMessages, _ := babble.All(db)
newMessages := make([]*babble.Babble, 0)
for _, msg := range allMessages {
if msg.ID > lastMessageID {
newMessages = append(newMessages, msg)
}
}
return newMessages
}
## Chat Commands
Process special chat commands:
func processMessage(db *database.DB, author, content string) {
// Check for commands
if strings.HasPrefix(content, "/") {
handleCommand(author, content)
return
}
// Regular chat message
msg, err := babble.NewBuilder(db).
WithAuthor(author).
WithBabble(content).
Create()
if err != nil {
log.Printf("Failed to save message: %v", err)
return
}
// Process mentions for notifications
mentions := msg.GetMentions()
for _, username := range mentions {
sendMentionNotification(username, msg)
}
}
# Error Handling
All functions return appropriate errors for common failure cases:
- Message not found (Find returns error for non-existent IDs)
- Database connection issues
- Invalid operations (e.g., saving/deleting messages without IDs)
- Search query errors
- Time range validation errors
*/
package babble

View File

@ -4,34 +4,56 @@ import (
"fmt" "fmt"
"dk/internal/database" "dk/internal/database"
"dk/internal/helpers/scanner"
"zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite"
) )
// Control represents the game control settings in the database // Control represents the game control settings in the database
type Control struct { type Control struct {
ID int `json:"id"` ID int `db:"id" json:"id"`
WorldSize int `json:"world_size"` WorldSize int `db:"world_size" json:"world_size"`
Open int `json:"open"` Open int `db:"open" json:"open"`
AdminEmail string `json:"admin_email"` AdminEmail string `db:"admin_email" json:"admin_email"`
Class1Name string `json:"class_1_name"` Class1Name string `db:"class_1_name" json:"class_1_name"`
Class2Name string `json:"class_2_name"` Class2Name string `db:"class_2_name" json:"class_2_name"`
Class3Name string `json:"class_3_name"` Class3Name string `db:"class_3_name" json:"class_3_name"`
}
// New creates a new Control with sensible defaults
func New() *Control {
return &Control{
WorldSize: 200, // Default world size
Open: 1, // Default open for registration
AdminEmail: "", // No admin email by default
Class1Name: "Mage", // Default class names
Class2Name: "Warrior",
Class3Name: "Paladin",
}
}
var controlScanner = scanner.New[Control]()
// controlColumns returns the column list for control queries
func controlColumns() string {
return controlScanner.Columns()
}
// scanControl populates a Control struct using the fast scanner
func scanControl(stmt *sqlite.Stmt) *Control {
control := &Control{}
controlScanner.Scan(stmt, control)
return control
} }
// Find retrieves the control record by ID (typically only ID 1 exists) // Find retrieves the control record by ID (typically only ID 1 exists)
func Find(id int) (*Control, error) { func Find(id int) (*Control, error) {
control := &Control{} var control *Control
query := `SELECT ` + controlColumns() + ` FROM control WHERE id = ?`
query := "SELECT id, world_size, open, admin_email, class_1_name, class_2_name, class_3_name FROM control WHERE id = ?"
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
control.ID = stmt.ColumnInt(0) control = scanControl(stmt)
control.WorldSize = stmt.ColumnInt(1)
control.Open = stmt.ColumnInt(2)
control.AdminEmail = stmt.ColumnText(3)
control.Class1Name = stmt.ColumnText(4)
control.Class2Name = stmt.ColumnText(5)
control.Class3Name = stmt.ColumnText(6)
return nil return nil
}, id) }, id)
@ -39,7 +61,7 @@ func Find(id int) (*Control, error) {
return nil, fmt.Errorf("failed to find control: %w", err) return nil, fmt.Errorf("failed to find control: %w", err)
} }
if control.ID == 0 { if control == nil {
return nil, fmt.Errorf("control with ID %d not found", id) return nil, fmt.Errorf("control with ID %d not found", id)
} }
@ -61,6 +83,46 @@ func (c *Control) Save() error {
return database.Exec(query, c.WorldSize, c.Open, c.AdminEmail, c.Class1Name, c.Class2Name, c.Class3Name, c.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 {
if c.ID != 0 {
return fmt.Errorf("control already has ID %d, use Save() to update", c.ID)
}
// 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
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 // 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
@ -198,3 +260,29 @@ 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

@ -1,416 +0,0 @@
/*
Package control is the active record implementation for game control settings.
The control package manages global game configuration settings stored in a single database record (ID 1). Unlike other packages, this one focuses on updating the existing control record rather than creating new ones, as there is only ever one set of game control settings.
# Basic Usage
To retrieve the main control settings:
settings, err := control.Get(db)
if err != nil {
log.Fatal(err)
}
fmt.Printf("World size: %d, Game open: %v\n", settings.WorldSize, settings.IsOpen())
To find control settings by ID (typically always 1):
settings, err := control.Find(db, 1)
if err != nil {
log.Fatal(err)
}
# Updating Settings
Control settings can be modified and saved back to the database:
settings, _ := control.Get(db)
settings.WorldSize = 500
settings.SetOpen(false)
settings.AdminEmail = "newadmin@game.com"
err := settings.Save()
if err != nil {
log.Fatal(err)
}
# Database Schema
The control table has the following structure:
CREATE TABLE control (
id INTEGER PRIMARY KEY AUTOINCREMENT,
world_size INTEGER NOT NULL DEFAULT 250,
open INTEGER NOT NULL DEFAULT 1,
admin_email TEXT NOT NULL DEFAULT '',
class_1_name TEXT NOT NULL DEFAULT '',
class_2_name TEXT NOT NULL DEFAULT '',
class_3_name TEXT NOT NULL DEFAULT ''
)
Where:
- id: Unique identifier (typically always 1)
- world_size: The size of the game world (used for coordinate bounds)
- open: Whether the game is open for new player registration (1=open, 0=closed)
- admin_email: Administrator email address for notifications
- class_1_name, class_2_name, class_3_name: Names of the three player classes
# Game World Management
## World Size and Boundaries
The world size determines the coordinate boundaries for the game:
settings, _ := control.Get(db)
// Get world size and radius
fmt.Printf("World size: %d\n", settings.WorldSize)
fmt.Printf("World radius: %d\n", settings.GetWorldRadius())
// Check coordinate boundaries
minX, minY, maxX, maxY := settings.GetWorldBounds()
fmt.Printf("World bounds: (%d,%d) to (%d,%d)\n", minX, minY, maxX, maxY)
// Validate coordinates
if settings.IsWithinWorldBounds(playerX, playerY) {
fmt.Println("Player is within world boundaries")
}
The world is centered at (0,0) with coordinates ranging from -radius to +radius.
## World Size Validation
Ensure world size settings are reasonable:
settings, _ := control.Get(db)
if !settings.IsWorldSizeValid() {
fmt.Println("Warning: World size is invalid (must be 1-10000)")
settings.WorldSize = 250 // Reset to default
settings.Save()
}
# Player Registration Control
## Managing Game Access
Control whether new players can register:
settings, _ := control.Get(db)
// Check current status
if settings.IsOpen() {
fmt.Println("Game is open for new players")
} else {
fmt.Println("Game is closed to new players")
}
// Change registration status
settings.SetOpen(false) // Close registration
settings.Close() // Alternative method
settings.OpenWorld() // Reopen registration
settings.Save()
## Maintenance Mode
Temporarily close the game for maintenance:
func enterMaintenanceMode(db *database.DB) error {
settings, err := control.Get(db)
if err != nil {
return err
}
settings.Close()
return settings.Save()
}
func exitMaintenanceMode(db *database.DB) error {
settings, err := control.Get(db)
if err != nil {
return err
}
settings.OpenWorld()
return settings.Save()
}
# Player Class Management
## Class Configuration
Manage the three player classes available in the game:
settings, _ := control.Get(db)
// Get all configured class names
classes := settings.GetClassNames()
fmt.Printf("Available classes: %v\n", classes)
// Get specific class name
mageClass := settings.GetClassName(1)
fmt.Printf("Class 1: %s\n", mageClass)
// Set all class names at once
newClasses := []string{"Sorcerer", "Paladin", "Assassin"}
settings.SetClassNames(newClasses)
settings.Save()
## Individual Class Management
Manage classes individually:
settings, _ := control.Get(db)
// Set individual class names
settings.SetClassName(1, "Wizard")
settings.SetClassName(2, "Knight")
settings.SetClassName(3, "Rogue")
settings.Save()
// Validate class names
if settings.IsValidClassName("Wizard") {
fmt.Println("Wizard is a valid class")
}
// Get class number by name
classNum := settings.GetClassNumber("Knight")
fmt.Printf("Knight is class number: %d\n", classNum)
## Class System Integration
Use class settings for player creation and validation:
func validatePlayerClass(db *database.DB, className string) bool {
settings, err := control.Get(db)
if err != nil {
return false
}
return settings.IsValidClassName(className)
}
func getAvailableClasses(db *database.DB) []string {
settings, err := control.Get(db)
if err != nil {
return []string{}
}
return settings.GetClassNames()
}
func getClassID(db *database.DB, className string) int {
settings, err := control.Get(db)
if err != nil {
return 0
}
return settings.GetClassNumber(className)
}
# Administrative Features
## Admin Contact Information
Manage administrator contact information:
settings, _ := control.Get(db)
// Check if admin email is configured
if settings.HasAdminEmail() {
fmt.Printf("Admin contact: %s\n", settings.AdminEmail)
} else {
fmt.Println("No admin email configured")
settings.AdminEmail = "admin@mygame.com"
settings.Save()
}
## Configuration Validation
Validate all control settings:
func validateControlSettings(db *database.DB) []string {
settings, err := control.Get(db)
if err != nil {
return []string{"Failed to load control settings"}
}
var issues []string
// Check world size
if !settings.IsWorldSizeValid() {
issues = append(issues, "Invalid world size")
}
// Check admin email
if !settings.HasAdminEmail() {
issues = append(issues, "No admin email configured")
}
// Check class names
classes := settings.GetClassNames()
if len(classes) == 0 {
issues = append(issues, "No player classes configured")
}
return issues
}
# Game Logic Integration
## Coordinate Validation
Use control settings for game coordinate validation:
func validatePlayerMovement(db *database.DB, newX, newY int) bool {
settings, err := control.Get(db)
if err != nil {
return false
}
return settings.IsWithinWorldBounds(newX, newY)
}
## Registration System
Integrate with player registration:
func canRegisterNewPlayer(db *database.DB) bool {
settings, err := control.Get(db)
if err != nil {
return false
}
return settings.IsOpen()
}
func getRegistrationMessage(db *database.DB) string {
settings, err := control.Get(db)
if err != nil {
return "Unable to check registration status"
}
if settings.IsOpen() {
classes := settings.GetClassNames()
return fmt.Sprintf("Welcome! Choose from these classes: %v", classes)
} else {
return "Registration is currently closed"
}
}
# Configuration Management
## Backup and Restore
Backup control settings:
func backupControlSettings(db *database.DB) (*control.Control, error) {
return control.Get(db)
}
func restoreControlSettings(db *database.DB, backup *control.Control) error {
settings, err := control.Get(db)
if err != nil {
return err
}
settings.WorldSize = backup.WorldSize
settings.Open = backup.Open
settings.AdminEmail = backup.AdminEmail
settings.Class1Name = backup.Class1Name
settings.Class2Name = backup.Class2Name
settings.Class3Name = backup.Class3Name
return settings.Save()
}
## Default Settings
Reset to default configuration:
func resetToDefaults(db *database.DB) error {
settings, err := control.Get(db)
if err != nil {
return err
}
settings.WorldSize = 250
settings.SetOpen(true)
settings.AdminEmail = ""
settings.SetClassNames([]string{"Mage", "Warrior", "Paladin"})
return settings.Save()
}
# Performance Considerations
## Caching Settings
Since control settings rarely change, consider caching:
var controlCache *control.Control
var cacheTime time.Time
func getCachedControlSettings(db *database.DB) (*control.Control, error) {
// Cache for 5 minutes
if controlCache != nil && time.Since(cacheTime) < 5*time.Minute {
return controlCache, nil
}
settings, err := control.Get(db)
if err != nil {
return nil, err
}
controlCache = settings
cacheTime = time.Now()
return settings, nil
}
## Batch Updates
Update multiple settings efficiently:
func updateGameConfiguration(db *database.DB, worldSize int, isOpen bool,
adminEmail string, classes []string) error {
settings, err := control.Get(db)
if err != nil {
return err
}
// Update all settings
settings.WorldSize = worldSize
settings.SetOpen(isOpen)
settings.AdminEmail = adminEmail
settings.SetClassNames(classes)
// Single save operation
return settings.Save()
}
# Error Handling
Common error scenarios and handling:
settings, err := control.Get(db)
if err != nil {
// Handle database connection issues or missing control record
log.Printf("Failed to load control settings: %v", err)
return
}
// Validate before using
if !settings.IsWorldSizeValid() {
log.Println("Warning: Invalid world size detected")
// Could reset to default or reject changes
}
// Save with error handling
if err := settings.Save(); err != nil {
log.Printf("Failed to save control settings: %v", err)
// Could retry or alert administrator
}
The control package provides a centralized way to manage all global game settings through a single, persistent record that can be easily modified and validated.
*/
package control

View File

@ -1,85 +0,0 @@
package drops
import (
"dk/internal/database"
"fmt"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating drops
type Builder struct {
drop *Drop
}
// NewBuilder creates a new drop builder
func NewBuilder() *Builder {
return &Builder{
drop: &Drop{},
}
}
// WithName sets the drop name
func (b *Builder) WithName(name string) *Builder {
b.drop.Name = name
return b
}
// WithLevel sets the drop level requirement
func (b *Builder) WithLevel(level int) *Builder {
b.drop.Level = level
return b
}
// WithType sets the drop type
func (b *Builder) WithType(dropType int) *Builder {
b.drop.Type = dropType
return b
}
// WithAtt sets the attributes
func (b *Builder) WithAtt(att string) *Builder {
b.drop.Att = att
return b
}
// Create saves the drop to the database and returns it
func (b *Builder) Create() (*Drop, error) {
// Use a transaction to ensure we can get the ID
var drop *Drop
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO drops (name, level, type, att)
VALUES (?, ?, ?, ?)`
if err := tx.Exec(query, b.drop.Name, b.drop.Level, b.drop.Type, b.drop.Att); err != nil {
return fmt.Errorf("failed to insert drop: %w", err)
}
// Get the last inserted ID within the same transaction
var lastID int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
lastID = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get last insert ID: %w", err)
}
// Create the drop with the ID
drop = &Drop{
ID: lastID,
Name: b.drop.Name,
Level: b.drop.Level,
Type: b.drop.Type,
Att: b.drop.Att,
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to create drop: %w", err)
}
return drop, nil
}

View File

@ -1,147 +0,0 @@
/*
Package drops is the active record implementation for drop items in the game.
# Basic Usage
To retrieve a drop by ID:
drop, err := drops.Find(db, 1)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found drop: %s (level: %d)\n", drop.Name, drop.Level)
To get all drops:
allDrops, err := drops.All(db)
if err != nil {
log.Fatal(err)
}
for _, drop := range allDrops {
fmt.Printf("Drop: %s\n", drop.Name)
}
To filter drops by level (items available at or below a level):
availableDrops, err := drops.ByLevel(db, 25)
if err != nil {
log.Fatal(err)
}
To filter drops by type:
consumables, err := drops.ByType(db, drops.TypeConsumable)
if err != nil {
log.Fatal(err)
}
# Creating Drops with Builder Pattern
The package provides a fluent builder interface for creating new drops:
drop, err := drops.NewBuilder(db).
WithName("Ruby").
WithLevel(50).
WithType(drops.TypeConsumable).
WithAtt("maxhp,150").
Create()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Created drop with ID: %d\n", drop.ID)
# Updating Drops
Drops can be modified and saved back to the database:
drop, _ := drops.Find(db, 1)
drop.Name = "Enhanced Life Pebble"
drop.Level = 5
drop.Att = "maxhp,15"
err := drop.Save()
if err != nil {
log.Fatal(err)
}
# Deleting Drops
Drops can be removed from the database:
drop, _ := drops.Find(db, 1)
err := drop.Delete()
if err != nil {
log.Fatal(err)
}
# Drop Types
The package defines drop type constants:
drops.TypeConsumable = 1 // Consumable items like potions, gems, etc.
Helper methods are available to check drop types:
if drop.IsConsumable() {
fmt.Println("This is a consumable item")
}
fmt.Printf("Drop type: %s\n", drop.TypeName())
# Database Schema
The drops table has the following structure:
CREATE TABLE drops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
level INTEGER NOT NULL DEFAULT 0,
type INTEGER NOT NULL DEFAULT 0,
att TEXT NOT NULL DEFAULT ''
)
Where:
- id: Unique identifier
- name: Display name of the drop
- level: Minimum monster level to find this drop
- type: Drop type (1=consumable)
- att: Comma-separated attributes in "key,value,key,value" format
# Drop Attributes
The att field contains attribute bonuses in comma-separated "key,value" pairs:
"maxhp,10" // +10 max health
"maxmp,25" // +25 max mana
"strength,50" // +50 strength
"defensepower,25" // +25 defense power
"expbonus,10" // +10% experience bonus
"goldbonus,5" // +5% gold bonus
Many drops have multiple attributes in a single field:
drop.Att = "maxhp,25,strength,25" // +25 max health AND +25 strength
drop.Att = "maxmp,-50,strength,100" // -50 max mana AND +100 strength
The attributes are parsed as alternating key-value pairs separated by commas.
# Level Requirements
Drops have level requirements that determine when players can use them:
// Get all drops available from level 10 and above monsters
availableDrops, err := drops.ByLevel(db, 10)
// This returns drops with level <= 10
for _, drop := range availableDrops {
fmt.Printf("%s (level %d)\n", drop.Name, drop.Level)
}
# Error Handling
All functions return appropriate errors for common failure cases:
- Drop not found (Find returns error for non-existent IDs)
- Database connection issues
- Invalid operations (e.g., saving/deleting drops without IDs)
*/
package drops

View File

@ -4,17 +4,42 @@ import (
"fmt" "fmt"
"dk/internal/database" "dk/internal/database"
"dk/internal/helpers/scanner"
"zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite"
) )
// Drop represents a drop item in the database // Drop represents a drop item in the database
type Drop struct { type Drop struct {
ID int `json:"id"` ID int `db:"id" json:"id"`
Name string `json:"name"` Name string `db:"name" json:"name"`
Level int `json:"level"` Level int `db:"level" json:"level"`
Type int `json:"type"` Type int `db:"type" json:"type"`
Att string `json:"att"` Att string `db:"att" json:"att"`
}
// New creates a new Drop with sensible defaults
func New() *Drop {
return &Drop{
Name: "",
Level: 1, // Default minimum level
Type: TypeConsumable, // Default to consumable
Att: "",
}
}
var dropScanner = scanner.New[Drop]()
// dropColumns returns the column list for drop queries
func dropColumns() string {
return dropScanner.Columns()
}
// scanDrop populates a Drop struct using the fast scanner
func scanDrop(stmt *sqlite.Stmt) *Drop {
drop := &Drop{}
dropScanner.Scan(stmt, drop)
return drop
} }
// DropType constants for drop types // DropType constants for drop types
@ -24,15 +49,12 @@ const (
// Find retrieves a drop by ID // Find retrieves a drop by ID
func Find(id int) (*Drop, error) { func Find(id int) (*Drop, error) {
drop := &Drop{} var drop *Drop
query := `SELECT ` + dropColumns() + ` FROM drops WHERE id = ?`
query := "SELECT id, name, level, type, att FROM drops WHERE id = ?"
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
drop.ID = stmt.ColumnInt(0) drop = scanDrop(stmt)
drop.Name = stmt.ColumnText(1)
drop.Level = stmt.ColumnInt(2)
drop.Type = stmt.ColumnInt(3)
drop.Att = stmt.ColumnText(4)
return nil return nil
}, id) }, id)
@ -40,7 +62,7 @@ func Find(id int) (*Drop, error) {
return nil, fmt.Errorf("failed to find drop: %w", err) return nil, fmt.Errorf("failed to find drop: %w", err)
} }
if drop.ID == 0 { if drop == nil {
return nil, fmt.Errorf("drop with ID %d not found", id) return nil, fmt.Errorf("drop with ID %d not found", id)
} }
@ -51,15 +73,10 @@ func Find(id int) (*Drop, error) {
func All() ([]*Drop, error) { func All() ([]*Drop, error) {
var drops []*Drop var drops []*Drop
query := "SELECT id, name, level, type, att FROM drops ORDER BY id" query := `SELECT ` + dropColumns() + ` FROM drops ORDER BY id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
drop := &Drop{ drop := scanDrop(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
Level: stmt.ColumnInt(2),
Type: stmt.ColumnInt(3),
Att: stmt.ColumnText(4),
}
drops = append(drops, drop) drops = append(drops, drop)
return nil return nil
}) })
@ -75,15 +92,10 @@ func All() ([]*Drop, error) {
func ByLevel(minLevel int) ([]*Drop, error) { func ByLevel(minLevel int) ([]*Drop, error) {
var drops []*Drop var drops []*Drop
query := "SELECT id, name, level, type, att FROM drops WHERE level <= ? ORDER BY level, id" query := `SELECT ` + dropColumns() + ` FROM drops WHERE level <= ? ORDER BY level, id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
drop := &Drop{ drop := scanDrop(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
Level: stmt.ColumnInt(2),
Type: stmt.ColumnInt(3),
Att: stmt.ColumnText(4),
}
drops = append(drops, drop) drops = append(drops, drop)
return nil return nil
}, minLevel) }, minLevel)
@ -99,15 +111,10 @@ func ByLevel(minLevel int) ([]*Drop, error) {
func ByType(dropType int) ([]*Drop, error) { func ByType(dropType int) ([]*Drop, error) {
var drops []*Drop var drops []*Drop
query := "SELECT id, name, level, type, att FROM drops WHERE type = ? ORDER BY level, id" query := `SELECT ` + dropColumns() + ` FROM drops WHERE type = ? ORDER BY level, id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
drop := &Drop{ drop := scanDrop(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
Level: stmt.ColumnInt(2),
Type: stmt.ColumnInt(3),
Att: stmt.ColumnText(4),
}
drops = append(drops, drop) drops = append(drops, drop)
return nil return nil
}, dropType) }, dropType)
@ -129,6 +136,37 @@ func (d *Drop) Save() error {
return database.Exec(query, d.Name, d.Level, d.Type, d.Att, d.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 {
if d.ID != 0 {
return fmt.Errorf("drop already has ID %d, use Save() to update", d.ID)
}
// 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 // Delete removes the drop from the database
func (d *Drop) Delete() error { func (d *Drop) Delete() error {
if d.ID == 0 { if d.ID == 0 {
@ -152,3 +190,18 @@ 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

@ -1,128 +0,0 @@
package forum
import (
"fmt"
"time"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating forum posts
type Builder struct {
forum *Forum
}
// NewBuilder creates a new forum post builder
func NewBuilder() *Builder {
now := time.Now().Unix()
return &Builder{
forum: &Forum{
Posted: now,
LastPost: now, // Default to same as posted time
Parent: 0, // Default to thread (no parent)
Replies: 0, // Default to no replies
},
}
}
// WithAuthor sets the author user ID
func (b *Builder) WithAuthor(authorID int) *Builder {
b.forum.Author = authorID
return b
}
// WithTitle sets the forum post title
func (b *Builder) WithTitle(title string) *Builder {
b.forum.Title = title
return b
}
// WithContent sets the forum post content
func (b *Builder) WithContent(content string) *Builder {
b.forum.Content = content
return b
}
// WithParent sets the parent post ID (for replies)
func (b *Builder) WithParent(parentID int) *Builder {
b.forum.Parent = parentID
return b
}
// AsReply is a convenience method to set this as a reply to another post
func (b *Builder) AsReply(parentID int) *Builder {
return b.WithParent(parentID)
}
// AsThread ensures this is a top-level thread (parent = 0)
func (b *Builder) AsThread() *Builder {
b.forum.Parent = 0
return b
}
// WithPosted sets the posted timestamp
func (b *Builder) WithPosted(posted int64) *Builder {
b.forum.Posted = posted
return b
}
// WithPostedTime sets the posted timestamp from a time.Time
func (b *Builder) WithPostedTime(t time.Time) *Builder {
b.forum.Posted = t.Unix()
return b
}
// WithLastPost sets the last post timestamp
func (b *Builder) WithLastPost(lastPost int64) *Builder {
b.forum.LastPost = lastPost
return b
}
// WithLastPostTime sets the last post timestamp from a time.Time
func (b *Builder) WithLastPostTime(t time.Time) *Builder {
b.forum.LastPost = t.Unix()
return b
}
// WithReplies sets the initial reply count
func (b *Builder) WithReplies(replies int) *Builder {
b.forum.Replies = replies
return b
}
// Create saves the forum post to the database and returns the created post with ID
func (b *Builder) Create() (*Forum, error) {
// Use a transaction to ensure we can get the ID
var forum *Forum
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, b.forum.Posted, b.forum.LastPost, b.forum.Author,
b.forum.Parent, b.forum.Replies, b.forum.Title, b.forum.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)
}
b.forum.ID = id
forum = b.forum
return nil
})
if err != nil {
return nil, err
}
return forum, nil
}

View File

@ -1,627 +0,0 @@
/*
Package forum is the active record implementation for forum posts and threads in the game.
The forum package provides a complete forum system with thread and reply functionality, search capabilities, and comprehensive post management. It supports hierarchical discussions with parent/child relationships between posts.
# Basic Usage
To retrieve a forum post by ID:
post, err := forum.Find(db, 1)
if err != nil {
log.Fatal(err)
}
fmt.Printf("[%s] %s by user %d\n",
post.PostedTime().Format("Jan 2"), post.Title, post.Author)
To get all forum threads (top-level posts):
threads, err := forum.Threads(db)
if err != nil {
log.Fatal(err)
}
for _, thread := range threads {
fmt.Printf("Thread: %s (%d replies)\n", thread.Title, thread.Replies)
}
To get replies to a specific thread:
replies, err := forum.ByParent(db, threadID)
if err != nil {
log.Fatal(err)
}
To search forum posts:
results, err := forum.Search(db, "strategy")
if err != nil {
log.Fatal(err)
}
# Creating Posts with Builder Pattern
The package provides a fluent builder interface for creating new forum posts:
## Creating a New Thread
thread, err := forum.NewBuilder(db).
WithAuthor(userID).
WithTitle("New Strategy Discussion").
WithContent("What are your thoughts on the best character builds?").
AsThread().
Create()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Created thread with ID: %d\n", thread.ID)
## Creating a Reply
reply, err := forum.NewBuilder(db).
WithAuthor(userID).
WithTitle("Re: Strategy Discussion").
WithContent("I think mage builds are overpowered right now.").
AsReply(parentThreadID).
Create()
if err != nil {
log.Fatal(err)
}
The builder automatically sets timestamps to the current time if not specified.
# Updating Posts
Forum posts can be modified and saved back to the database:
post, _ := forum.Find(db, 1)
post.Title = "[UPDATED] " + post.Title
post.Content = post.Content + "\n\nEDIT: Added clarification."
post.UpdateLastPost() // Update last activity timestamp
err := post.Save()
if err != nil {
log.Fatal(err)
}
# Deleting Posts
Posts can be removed from the database:
post, _ := forum.Find(db, 1)
err := post.Delete()
if err != nil {
log.Fatal(err)
}
# Database Schema
The forum table has the following structure:
CREATE TABLE forum (
id INTEGER PRIMARY KEY AUTOINCREMENT,
posted INTEGER NOT NULL DEFAULT (unixepoch()),
last_post INTEGER NOT NULL DEFAULT (unixepoch()),
author INTEGER NOT NULL,
parent INTEGER NOT NULL DEFAULT 0,
replies INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL,
content TEXT NOT NULL
)
Where:
- id: Unique identifier for the forum post
- posted: Unix timestamp when the post was originally created
- last_post: Unix timestamp of the most recent activity on this thread/post
- author: User ID of the post author
- parent: Parent post ID (0 for top-level threads, >0 for replies)
- replies: Number of direct replies to this post
- title: Post title/subject line
- content: Post content/body text
# Thread and Reply System
## Thread Structure
The forum uses a parent/child hierarchy:
// Top-level threads have parent = 0
if post.IsThread() {
fmt.Println("This is a main thread")
}
// Replies have parent > 0
if post.IsReply() {
fmt.Printf("This is a reply to post %d\n", post.Parent)
}
## Working with Threads
Get all top-level threads:
threads, _ := forum.Threads(db)
fmt.Println("=== Forum Threads ===")
for _, thread := range threads {
age := thread.ActivityAge()
var activityStr string
if age < time.Hour {
activityStr = fmt.Sprintf("%d minutes ago", int(age.Minutes()))
} else if age < 24*time.Hour {
activityStr = fmt.Sprintf("%d hours ago", int(age.Hours()))
} else {
activityStr = fmt.Sprintf("%d days ago", int(age.Hours()/24))
}
fmt.Printf("📌 %s (%d replies, last activity %s)\n",
thread.Title, thread.Replies, activityStr)
}
## Working with Replies
Get all replies to a thread:
thread, _ := forum.Find(db, threadID)
replies, _ := thread.GetReplies()
fmt.Printf("=== %s ===\n", thread.Title)
fmt.Printf("Posted by user %d on %s\n\n",
thread.Author, thread.PostedTime().Format("Jan 2, 2006"))
fmt.Println(thread.Content)
if len(replies) > 0 {
fmt.Printf("\n--- %d Replies ---\n", len(replies))
for i, reply := range replies {
fmt.Printf("[%d] by user %d on %s:\n%s\n\n",
i+1, reply.Author, reply.PostedTime().Format("Jan 2 15:04"),
reply.Content)
}
}
## Navigation Between Posts
Navigate the thread hierarchy:
reply, _ := forum.Find(db, replyID)
// Get the parent thread
thread, err := reply.GetThread()
if err == nil {
fmt.Printf("This reply belongs to thread: %s\n", thread.Title)
}
// Get all sibling replies
siblings, _ := forum.ByParent(db, reply.Parent)
fmt.Printf("This thread has %d total replies\n", len(siblings))
# Search and Filtering
## Text Search
Search within titles and content:
// Search for posts about "pvp"
pvpPosts, err := forum.Search(db, "pvp")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found %d posts about PvP:\n", len(pvpPosts))
for _, post := range pvpPosts {
fmt.Printf("- %s: %s\n", post.Title, post.Preview(60))
}
Search is case-insensitive and searches both titles and content.
## Author-Based Queries
Find posts by specific users:
// Get all posts by a user
userPosts, err := forum.ByAuthor(db, userID)
if err != nil {
log.Fatal(err)
}
fmt.Printf("User %d has made %d posts:\n", userID, len(userPosts))
for _, post := range userPosts {
postType := "thread"
if post.IsReply() {
postType = "reply"
}
fmt.Printf("- [%s] %s (%s ago)\n",
postType, post.Title, post.PostAge())
}
## Activity-Based Queries
Find recent activity:
// Get recent forum activity
recentPosts, err := forum.Recent(db, 20)
if err != nil {
log.Fatal(err)
}
// Get activity since user's last visit
lastVisit := getUserLastVisit(userID)
newActivity, err := forum.Since(db, lastVisit)
if len(newActivity) > 0 {
fmt.Printf("There have been %d new posts since your last visit\n",
len(newActivity))
}
# Post Management
## Reply Count Management
Manage reply counts when posts are created or deleted:
// When creating a reply
reply, err := forum.NewBuilder(db).
WithAuthor(userID).
WithContent("Great point!").
AsReply(parentID).
Create()
if err == nil {
// Update parent thread's reply count and last activity
parentThread, _ := forum.Find(db, parentID)
parentThread.IncrementReplies()
parentThread.UpdateLastPost()
parentThread.Save()
}
// When deleting a reply
replyToDelete, _ := forum.Find(db, replyID)
parentID := replyToDelete.Parent
replyToDelete.Delete()
// Update parent thread
if parentID > 0 {
parentThread, _ := forum.Find(db, parentID)
parentThread.DecrementReplies()
parentThread.Save()
}
## Content Analysis
Analyze post content:
post, _ := forum.Find(db, postID)
// Basic content metrics
fmt.Printf("Post length: %d characters\n", post.Length())
fmt.Printf("Word count: %d words\n", post.WordCount())
// Check for specific terms
if post.Contains("bug") {
fmt.Println("This post mentions a bug")
}
// Generate preview for listings
preview := post.Preview(100)
fmt.Printf("Preview: %s\n", preview)
## Time Analysis
Track posting and activity patterns:
post, _ := forum.Find(db, postID)
postAge := post.PostAge()
activityAge := post.ActivityAge()
fmt.Printf("Post created %v ago\n", postAge)
fmt.Printf("Last activity %v ago\n", activityAge)
if post.IsRecentActivity() {
fmt.Println("This thread has recent activity")
}
# Forum Display Patterns
## Thread Listing
Display forum index:
func displayForumIndex(db *database.DB) {
threads, _ := forum.Threads(db)
fmt.Println("=== Game Forum ===")
fmt.Printf("%-40s %-8s %-15s\n", "Thread", "Replies", "Last Activity")
fmt.Println(strings.Repeat("-", 65))
for _, thread := range threads {
title := thread.Title
if len(title) > 37 {
title = title[:37] + "..."
}
age := thread.ActivityAge()
var ageStr string
if age < time.Hour {
ageStr = fmt.Sprintf("%dm ago", int(age.Minutes()))
} else if age < 24*time.Hour {
ageStr = fmt.Sprintf("%dh ago", int(age.Hours()))
} else {
ageStr = fmt.Sprintf("%dd ago", int(age.Hours()/24))
}
fmt.Printf("%-40s %-8d %-15s\n", title, thread.Replies, ageStr)
}
}
## Thread View
Display a complete thread with replies:
func displayThread(db *database.DB, threadID int) error {
thread, err := forum.Find(db, threadID)
if err != nil {
return err
}
if !thread.IsThread() {
return fmt.Errorf("post %d is not a thread", threadID)
}
// Display thread
fmt.Printf("=== %s ===\n", thread.Title)
fmt.Printf("By user %d on %s\n\n",
thread.Author, thread.PostedTime().Format("January 2, 2006 at 3:04 PM"))
fmt.Println(thread.Content)
// Display replies
replies, _ := thread.GetReplies()
if len(replies) > 0 {
fmt.Printf("\n--- %d Replies ---\n\n", len(replies))
for i, reply := range replies {
fmt.Printf("#%d by user %d on %s:\n",
i+1, reply.Author, reply.PostedTime().Format("Jan 2 at 3:04 PM"))
fmt.Println(reply.Content)
fmt.Println()
}
}
return nil
}
# Moderation Features
## Content Moderation
Tools for forum moderation:
// Flag posts for review
func moderatePost(db *database.DB, postID int) {
post, err := forum.Find(db, postID)
if err != nil {
return
}
// Check for very short posts (potential spam)
if post.WordCount() < 3 {
fmt.Printf("Short post flagged: %s\n", post.Preview(30))
}
// Check for very long posts
if post.Length() > 5000 {
fmt.Printf("Very long post from user %d\n", post.Author)
}
// Check for specific terms
suspiciousTerms := []string{"spam", "hack", "cheat"}
for _, term := range suspiciousTerms {
if post.Contains(term) {
fmt.Printf("Post contains suspicious term '%s'\n", term)
}
}
}
## User Activity Analysis
Analyze user forum behavior:
func analyzeUserActivity(db *database.DB, userID int) {
posts, _ := forum.ByAuthor(db, userID)
fmt.Printf("User %d forum activity:\n", userID)
fmt.Printf("- Total posts: %d\n", len(posts))
threadCount := 0
replyCount := 0
totalWords := 0
for _, post := range posts {
if post.IsThread() {
threadCount++
} else {
replyCount++
}
totalWords += post.WordCount()
}
fmt.Printf("- Threads started: %d\n", threadCount)
fmt.Printf("- Replies posted: %d\n", replyCount)
if len(posts) > 0 {
avgWords := totalWords / len(posts)
fmt.Printf("- Average words per post: %d\n", avgWords)
latest := posts[0] // ByAuthor returns newest first
fmt.Printf("- Last post: %s (%s ago)\n",
latest.Title, latest.PostAge())
}
}
# Performance Considerations
## Efficient Queries
Optimize database queries for forum performance:
// Use specific queries for common operations
threads, _ := forum.Threads(db) // More efficient than filtering All()
// Limit results for pagination
recentPosts, _ := forum.Recent(db, 25) // Get page worth of data
// Use specific parent queries
replies, _ := forum.ByParent(db, threadID) // Efficient for thread display
## Caching Strategies
Cache frequently accessed data:
// Cache popular threads
var popularThreadsCache []*forum.Forum
var cacheTime time.Time
func getPopularThreads(db *database.DB) []*forum.Forum {
// Refresh cache every 5 minutes
if time.Since(cacheTime) > 5*time.Minute {
threads, _ := forum.Threads(db)
// Sort by activity (replies + recent posts)
sort.Slice(threads, func(i, j int) bool {
scoreI := threads[i].Replies
scoreJ := threads[j].Replies
// Bonus for recent activity
if threads[i].IsRecentActivity() {
scoreI += 10
}
if threads[j].IsRecentActivity() {
scoreJ += 10
}
return scoreI > scoreJ
})
// Cache top 10
if len(threads) > 10 {
threads = threads[:10]
}
popularThreadsCache = threads
cacheTime = time.Now()
}
return popularThreadsCache
}
# Integration Examples
## User Notifications
Integrate with notification system:
func notifyNewReply(db *database.DB, replyID int) {
reply, err := forum.Find(db, replyID)
if err != nil {
return
}
// Get the parent thread
thread, err := reply.GetThread()
if err != nil {
return
}
// Notify thread author if different from reply author
if thread.Author != reply.Author {
message := fmt.Sprintf("New reply to your thread '%s'", thread.Title)
sendNotification(thread.Author, message)
}
// Notify other participants in the thread
allReplies, _ := thread.GetReplies()
participants := make(map[int]bool)
participants[thread.Author] = true
for _, r := range allReplies {
if r.Author != reply.Author && !participants[r.Author] {
message := fmt.Sprintf("New activity in thread '%s'", thread.Title)
sendNotification(r.Author, message)
participants[r.Author] = true
}
}
}
## Search Integration
Provide advanced search capabilities:
func advancedSearch(db *database.DB, query string, authorID int,
onlyThreads bool, since time.Time) []*forum.Forum {
var results []*forum.Forum
// Start with text search
if query != "" {
textResults, _ := forum.Search(db, query)
results = append(results, textResults...)
} else {
allPosts, _ := forum.All(db)
results = allPosts
}
// Apply filters
var filtered []*forum.Forum
sinceUnix := since.Unix()
for _, post := range results {
// Author filter
if authorID > 0 && post.Author != authorID {
continue
}
// Thread-only filter
if onlyThreads && !post.IsThread() {
continue
}
// Time filter
if post.LastPost < sinceUnix {
continue
}
filtered = append(filtered, post)
}
return filtered
}
# Error Handling
Common error scenarios and handling:
post, err := forum.Find(db, postID)
if err != nil {
// Handle post not found or database issues
log.Printf("Failed to find post %d: %v", postID, err)
return
}
// Validate post relationships
if post.IsReply() {
parentThread, err := post.GetThread()
if err != nil {
log.Printf("Warning: Reply %d has invalid parent %d",
post.ID, post.Parent)
}
}
// Save with error handling
if err := post.Save(); err != nil {
log.Printf("Failed to save post %d: %v", post.ID, err)
}
The forum package provides a complete forum system with hierarchical discussions, search capabilities, and comprehensive post management suitable for game communities.
*/
package forum

View File

@ -6,36 +6,59 @@ import (
"time" "time"
"dk/internal/database" "dk/internal/database"
"dk/internal/helpers/scanner"
"zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite"
) )
// 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 {
ID int `json:"id"` ID int `db:"id" json:"id"`
Posted int64 `json:"posted"` Posted int64 `db:"posted" json:"posted"`
LastPost int64 `json:"last_post"` LastPost int64 `db:"last_post" json:"last_post"`
Author int `json:"author"` Author int `db:"author" json:"author"`
Parent int `json:"parent"` Parent int `db:"parent" json:"parent"`
Replies int `json:"replies"` Replies int `db:"replies" json:"replies"`
Title string `json:"title"` Title string `db:"title" json:"title"`
Content string `json:"content"` Content string `db:"content" json:"content"`
}
// New creates a new Forum with sensible defaults
func New() *Forum {
now := time.Now().Unix()
return &Forum{
Posted: now,
LastPost: now,
Author: 0,
Parent: 0, // Default to thread (not reply)
Replies: 0,
Title: "",
Content: "",
}
}
var forumScanner = scanner.New[Forum]()
// forumColumns returns the column list for forum queries
func forumColumns() string {
return forumScanner.Columns()
}
// scanForum populates a Forum struct using the fast scanner
func scanForum(stmt *sqlite.Stmt) *Forum {
forum := &Forum{}
forumScanner.Scan(stmt, forum)
return forum
} }
// Find retrieves a forum post by ID // Find retrieves a forum post by ID
func Find(id int) (*Forum, error) { func Find(id int) (*Forum, error) {
forum := &Forum{} var forum *Forum
query := `SELECT ` + forumColumns() + ` FROM forum WHERE id = ?`
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE id = ?"
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
forum.ID = stmt.ColumnInt(0) forum = scanForum(stmt)
forum.Posted = stmt.ColumnInt64(1)
forum.LastPost = stmt.ColumnInt64(2)
forum.Author = stmt.ColumnInt(3)
forum.Parent = stmt.ColumnInt(4)
forum.Replies = stmt.ColumnInt(5)
forum.Title = stmt.ColumnText(6)
forum.Content = stmt.ColumnText(7)
return nil return nil
}, id) }, id)
@ -43,7 +66,7 @@ func Find(id int) (*Forum, error) {
return nil, fmt.Errorf("failed to find forum post: %w", err) return nil, fmt.Errorf("failed to find forum post: %w", err)
} }
if forum.ID == 0 { if forum == nil {
return nil, fmt.Errorf("forum post with ID %d not found", id) return nil, fmt.Errorf("forum post with ID %d not found", id)
} }
@ -54,18 +77,10 @@ func Find(id int) (*Forum, error) {
func All() ([]*Forum, error) { func All() ([]*Forum, error) {
var forums []*Forum var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum ORDER BY last_post DESC, id DESC" query := `SELECT ` + forumColumns() + ` FROM forum ORDER BY last_post DESC, id DESC`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{ forum := scanForum(stmt)
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
LastPost: stmt.ColumnInt64(2),
Author: stmt.ColumnInt(3),
Parent: stmt.ColumnInt(4),
Replies: stmt.ColumnInt(5),
Title: stmt.ColumnText(6),
Content: stmt.ColumnText(7),
}
forums = append(forums, forum) forums = append(forums, forum)
return nil return nil
}) })
@ -81,18 +96,10 @@ func All() ([]*Forum, error) {
func Threads() ([]*Forum, error) { func Threads() ([]*Forum, error) {
var forums []*Forum var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE parent = 0 ORDER BY last_post DESC, id DESC" query := `SELECT ` + forumColumns() + ` FROM forum WHERE parent = 0 ORDER BY last_post DESC, id DESC`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{ forum := scanForum(stmt)
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
LastPost: stmt.ColumnInt64(2),
Author: stmt.ColumnInt(3),
Parent: stmt.ColumnInt(4),
Replies: stmt.ColumnInt(5),
Title: stmt.ColumnText(6),
Content: stmt.ColumnText(7),
}
forums = append(forums, forum) forums = append(forums, forum)
return nil return nil
}) })
@ -108,18 +115,10 @@ func Threads() ([]*Forum, error) {
func ByParent(parentID int) ([]*Forum, error) { func ByParent(parentID int) ([]*Forum, error) {
var forums []*Forum var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE parent = ? ORDER BY posted ASC, id ASC" query := `SELECT ` + forumColumns() + ` FROM forum WHERE parent = ? ORDER BY posted ASC, id ASC`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{ forum := scanForum(stmt)
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
LastPost: stmt.ColumnInt64(2),
Author: stmt.ColumnInt(3),
Parent: stmt.ColumnInt(4),
Replies: stmt.ColumnInt(5),
Title: stmt.ColumnText(6),
Content: stmt.ColumnText(7),
}
forums = append(forums, forum) forums = append(forums, forum)
return nil return nil
}, parentID) }, parentID)
@ -135,18 +134,10 @@ func ByParent(parentID int) ([]*Forum, error) {
func ByAuthor(authorID int) ([]*Forum, error) { func ByAuthor(authorID int) ([]*Forum, error) {
var forums []*Forum var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE author = ? ORDER BY posted DESC, id DESC" query := `SELECT ` + forumColumns() + ` FROM forum WHERE author = ? ORDER BY posted DESC, id DESC`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{ forum := scanForum(stmt)
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
LastPost: stmt.ColumnInt64(2),
Author: stmt.ColumnInt(3),
Parent: stmt.ColumnInt(4),
Replies: stmt.ColumnInt(5),
Title: stmt.ColumnText(6),
Content: stmt.ColumnText(7),
}
forums = append(forums, forum) forums = append(forums, forum)
return nil return nil
}, authorID) }, authorID)
@ -162,18 +153,10 @@ func ByAuthor(authorID int) ([]*Forum, error) {
func Recent(limit int) ([]*Forum, error) { func Recent(limit int) ([]*Forum, error) {
var forums []*Forum var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum ORDER BY last_post DESC, id DESC LIMIT ?" query := `SELECT ` + forumColumns() + ` FROM forum ORDER BY last_post DESC, id DESC LIMIT ?`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{ forum := scanForum(stmt)
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
LastPost: stmt.ColumnInt64(2),
Author: stmt.ColumnInt(3),
Parent: stmt.ColumnInt(4),
Replies: stmt.ColumnInt(5),
Title: stmt.ColumnText(6),
Content: stmt.ColumnText(7),
}
forums = append(forums, forum) forums = append(forums, forum)
return nil return nil
}, limit) }, limit)
@ -189,20 +172,11 @@ func Recent(limit int) ([]*Forum, error) {
func Search(term string) ([]*Forum, error) { func Search(term string) ([]*Forum, error) {
var forums []*Forum var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE LOWER(title) LIKE LOWER(?) OR LOWER(content) LIKE LOWER(?) ORDER BY last_post DESC, id DESC" query := `SELECT ` + forumColumns() + ` FROM forum WHERE LOWER(title) LIKE LOWER(?) OR LOWER(content) LIKE LOWER(?) ORDER BY last_post DESC, id DESC`
searchTerm := "%" + term + "%" searchTerm := "%" + term + "%"
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{ forum := scanForum(stmt)
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
LastPost: stmt.ColumnInt64(2),
Author: stmt.ColumnInt(3),
Parent: stmt.ColumnInt(4),
Replies: stmt.ColumnInt(5),
Title: stmt.ColumnText(6),
Content: stmt.ColumnText(7),
}
forums = append(forums, forum) forums = append(forums, forum)
return nil return nil
}, searchTerm, searchTerm) }, searchTerm, searchTerm)
@ -218,18 +192,10 @@ func Search(term string) ([]*Forum, error) {
func Since(since int64) ([]*Forum, error) { func Since(since int64) ([]*Forum, error) {
var forums []*Forum var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE last_post >= ? ORDER BY last_post DESC, id DESC" query := `SELECT ` + forumColumns() + ` FROM forum WHERE last_post >= ? ORDER BY last_post DESC, id DESC`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{ forum := scanForum(stmt)
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
LastPost: stmt.ColumnInt64(2),
Author: stmt.ColumnInt(3),
Parent: stmt.ColumnInt(4),
Replies: stmt.ColumnInt(5),
Title: stmt.ColumnText(6),
Content: stmt.ColumnText(7),
}
forums = append(forums, forum) forums = append(forums, forum)
return nil return nil
}, since) }, since)
@ -251,6 +217,37 @@ func (f *Forum) Save() error {
return database.Exec(query, f.Posted, f.LastPost, f.Author, f.Parent, f.Replies, f.Title, f.Content, f.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 {
if f.ID != 0 {
return fmt.Errorf("forum post already has ID %d, use Save() to update", f.ID)
}
// 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 // Delete removes the forum post from the database
func (f *Forum) Delete() error { func (f *Forum) Delete() error {
if f.ID == 0 { if f.ID == 0 {
@ -397,3 +394,29 @@ func (f *Forum) GetThread() (*Forum, error) {
} }
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

@ -1,4 +1,4 @@
package utils package helpers
import "github.com/valyala/fasthttp" import "github.com/valyala/fasthttp"

View File

@ -1,92 +0,0 @@
package items
import (
"dk/internal/database"
"fmt"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating items
type Builder struct {
item *Item
}
// NewBuilder creates a new item builder
func NewBuilder() *Builder {
return &Builder{
item: &Item{},
}
}
// WithType sets the item type
func (b *Builder) WithType(itemType int) *Builder {
b.item.Type = itemType
return b
}
// WithName sets the item name
func (b *Builder) WithName(name string) *Builder {
b.item.Name = name
return b
}
// WithValue sets the item value
func (b *Builder) WithValue(value int) *Builder {
b.item.Value = value
return b
}
// WithAtt sets the item attack/defense value
func (b *Builder) WithAtt(att int) *Builder {
b.item.Att = att
return b
}
// WithSpecial sets the item special attributes
func (b *Builder) WithSpecial(special string) *Builder {
b.item.Special = special
return b
}
// Create saves the item to the database and returns it
func (b *Builder) Create() (*Item, error) {
// Use a transaction to ensure we can get the ID
var item *Item
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO items (type, name, value, att, special)
VALUES (?, ?, ?, ?, ?)`
if err := tx.Exec(query, b.item.Type, b.item.Name, b.item.Value, b.item.Att, b.item.Special); err != nil {
return fmt.Errorf("failed to insert item: %w", err)
}
// Get the last inserted ID within the same transaction
var lastID int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
lastID = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get last insert ID: %w", err)
}
// Create the item with the ID
item = &Item{
ID: lastID,
Type: b.item.Type,
Name: b.item.Name,
Value: b.item.Value,
Att: b.item.Att,
Special: b.item.Special,
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to create item: %w", err)
}
return item, nil
}

View File

@ -1,123 +0,0 @@
/*
Package items is the active record implementation for items in the game.
# Basic Usage
To retrieve an item by ID:
item, err := items.Find(db, 1)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found item: %s (value: %d)\n", item.Name, item.Value)
To get all items:
allItems, err := items.All(db)
if err != nil {
log.Fatal(err)
}
for _, item := range allItems {
fmt.Printf("Item: %s\n", item.Name)
}
To filter items by type:
weapons, err := items.ByType(db, items.TypeWeapon)
if err != nil {
log.Fatal(err)
}
# Creating Items with Builder Pattern
The package provides a fluent builder interface for creating new items:
item, err := items.NewBuilder(db).
WithType(items.TypeWeapon).
WithName("Excalibur").
WithValue(5000).
WithAtt(100).
WithSpecial("strength,25").
Create()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Created item with ID: %d\n", item.ID)
# Updating Items
Items can be modified and saved back to the database:
item, _ := items.Find(db, 1)
item.Name = "Enhanced Sword"
item.Value += 100
err := item.Save()
if err != nil {
log.Fatal(err)
}
# Deleting Items
Items can be removed from the database:
item, _ := items.Find(db, 1)
err := item.Delete()
if err != nil {
log.Fatal(err)
}
# Item Types
The package defines three item types as constants:
items.TypeWeapon = 1 // Swords, axes, etc.
items.TypeArmor = 2 // Protective gear
items.TypeShield = 3 // Shields and bucklers
Helper methods are available to check item types:
if item.IsWeapon() {
fmt.Println("This is a weapon")
}
fmt.Printf("Item type: %s\n", item.TypeName())
# Database Schema
The items table has the following structure:
CREATE TABLE items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL,
value INTEGER NOT NULL DEFAULT 0,
att INTEGER NOT NULL DEFAULT 0,
special TEXT NOT NULL DEFAULT ''
)
Where:
- id: Unique identifier
- type: Item type (1=weapon, 2=armor, 3=shield)
- name: Display name of the item
- value: Gold value/cost
- att: Attack or defense attribute bonus
- special: Special attributes in "key,value" format
# Special Attributes
The special field contains comma-separated key-value pairs for item bonuses:
"strength,10" // +10 strength
"maxhp,25" // +25 max health
"expbonus,5" // +5% experience bonus
"maxhp,50,strength,25" // Multiple bonuses
# Error Handling
All functions return appropriate errors for common failure cases:
- Item not found (Find returns error for non-existent IDs)
- Database connection issues
- Invalid operations (e.g., saving/deleting items without IDs)
*/
package items

View File

@ -4,18 +4,44 @@ import (
"fmt" "fmt"
"dk/internal/database" "dk/internal/database"
"dk/internal/helpers/scanner"
"zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite"
) )
// Item represents an item in the database // Item represents an item in the database
type Item struct { type Item struct {
ID int `json:"id"` ID int `db:"id" json:"id"`
Type int `json:"type"` Type int `db:"type" json:"type"`
Name string `json:"name"` Name string `db:"name" json:"name"`
Value int `json:"value"` Value int `db:"value" json:"value"`
Att int `json:"att"` Att int `db:"att" json:"att"`
Special string `json:"special"` Special string `db:"special" json:"special"`
}
// New creates a new Item with sensible defaults
func New() *Item {
return &Item{
Type: TypeWeapon, // Default to weapon
Name: "",
Value: 0,
Att: 0,
Special: "",
}
}
var itemScanner = scanner.New[Item]()
// itemColumns returns the column list for item queries
func itemColumns() string {
return itemScanner.Columns()
}
// scanItem populates an Item struct using the fast scanner
func scanItem(stmt *sqlite.Stmt) *Item {
item := &Item{}
itemScanner.Scan(stmt, item)
return item
} }
// ItemType constants for item types // ItemType constants for item types
@ -27,16 +53,12 @@ const (
// Find retrieves an item by ID // Find retrieves an item by ID
func Find(id int) (*Item, error) { func Find(id int) (*Item, error) {
item := &Item{} var item *Item
query := `SELECT ` + itemColumns() + ` FROM items WHERE id = ?`
query := "SELECT id, type, name, value, att, special FROM items WHERE id = ?"
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
item.ID = stmt.ColumnInt(0) item = scanItem(stmt)
item.Type = stmt.ColumnInt(1)
item.Name = stmt.ColumnText(2)
item.Value = stmt.ColumnInt(3)
item.Att = stmt.ColumnInt(4)
item.Special = stmt.ColumnText(5)
return nil return nil
}, id) }, id)
@ -44,7 +66,7 @@ func Find(id int) (*Item, error) {
return nil, fmt.Errorf("failed to find item: %w", err) return nil, fmt.Errorf("failed to find item: %w", err)
} }
if item.ID == 0 { if item == nil {
return nil, fmt.Errorf("item with ID %d not found", id) return nil, fmt.Errorf("item with ID %d not found", id)
} }
@ -55,16 +77,10 @@ func Find(id int) (*Item, error) {
func All() ([]*Item, error) { func All() ([]*Item, error) {
var items []*Item var items []*Item
query := "SELECT id, type, name, value, att, special FROM items ORDER BY id" query := `SELECT ` + itemColumns() + ` FROM items ORDER BY id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
item := &Item{ item := scanItem(stmt)
ID: stmt.ColumnInt(0),
Type: stmt.ColumnInt(1),
Name: stmt.ColumnText(2),
Value: stmt.ColumnInt(3),
Att: stmt.ColumnInt(4),
Special: stmt.ColumnText(5),
}
items = append(items, item) items = append(items, item)
return nil return nil
}) })
@ -80,16 +96,10 @@ func All() ([]*Item, error) {
func ByType(itemType int) ([]*Item, error) { func ByType(itemType int) ([]*Item, error) {
var items []*Item var items []*Item
query := "SELECT id, type, name, value, att, special FROM items WHERE type = ? ORDER BY id" query := `SELECT ` + itemColumns() + ` FROM items WHERE type = ? ORDER BY id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
item := &Item{ item := scanItem(stmt)
ID: stmt.ColumnInt(0),
Type: stmt.ColumnInt(1),
Name: stmt.ColumnText(2),
Value: stmt.ColumnInt(3),
Att: stmt.ColumnInt(4),
Special: stmt.ColumnText(5),
}
items = append(items, item) items = append(items, item)
return nil return nil
}, itemType) }, itemType)
@ -111,6 +121,37 @@ func (i *Item) Save() error {
return database.Exec(query, i.Type, i.Name, i.Value, i.Att, i.Special, i.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 {
if i.ID != 0 {
return fmt.Errorf("item already has ID %d, use Save() to update", i.ID)
}
// 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 // Delete removes the item from the database
func (i *Item) Delete() error { func (i *Item) Delete() error {
if i.ID == 0 { if i.ID == 0 {
@ -149,3 +190,33 @@ func (i *Item) TypeName() string {
return "Unknown" return "Unknown"
} }
} }
// HasSpecial returns true if the item has special properties
func (i *Item) HasSpecial() bool {
return i.Special != ""
}
// IsEquippable returns true if the item can be equipped
func (i *Item) IsEquippable() bool {
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

@ -1,114 +0,0 @@
package monsters
import (
"dk/internal/database"
"fmt"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating monsters
type Builder struct {
monster *Monster
}
// NewBuilder creates a new monster builder
func NewBuilder() *Builder {
return &Builder{
monster: &Monster{},
}
}
// WithName sets the monster name
func (b *Builder) WithName(name string) *Builder {
b.monster.Name = name
return b
}
// WithMaxHP sets the monster's maximum hit points
func (b *Builder) WithMaxHP(maxHP int) *Builder {
b.monster.MaxHP = maxHP
return b
}
// WithMaxDmg sets the monster's maximum damage
func (b *Builder) WithMaxDmg(maxDmg int) *Builder {
b.monster.MaxDmg = maxDmg
return b
}
// WithArmor sets the monster's armor value
func (b *Builder) WithArmor(armor int) *Builder {
b.monster.Armor = armor
return b
}
// WithLevel sets the monster's level
func (b *Builder) WithLevel(level int) *Builder {
b.monster.Level = level
return b
}
// WithMaxExp sets the monster's maximum experience reward
func (b *Builder) WithMaxExp(maxExp int) *Builder {
b.monster.MaxExp = maxExp
return b
}
// WithMaxGold sets the monster's maximum gold reward
func (b *Builder) WithMaxGold(maxGold int) *Builder {
b.monster.MaxGold = maxGold
return b
}
// WithImmunity sets the monster's immunity type
func (b *Builder) WithImmunity(immunity int) *Builder {
b.monster.Immune = immunity
return b
}
// Create saves the monster to the database and returns it
func (b *Builder) Create() (*Monster, error) {
// Use a transaction to ensure we can get the ID
var monster *Monster
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, b.monster.Name, b.monster.MaxHP, b.monster.MaxDmg, b.monster.Armor,
b.monster.Level, b.monster.MaxExp, b.monster.MaxGold, b.monster.Immune); err != nil {
return fmt.Errorf("failed to insert monster: %w", err)
}
// Get the last inserted ID within the same transaction
var lastID int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
lastID = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get last insert ID: %w", err)
}
// Create the monster with the ID
monster = &Monster{
ID: lastID,
Name: b.monster.Name,
MaxHP: b.monster.MaxHP,
MaxDmg: b.monster.MaxDmg,
Armor: b.monster.Armor,
Level: b.monster.Level,
MaxExp: b.monster.MaxExp,
MaxGold: b.monster.MaxGold,
Immune: b.monster.Immune,
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to create monster: %w", err)
}
return monster, nil
}

View File

@ -1,207 +0,0 @@
/*
Package monsters is the active record implementation for monsters in the game.
# Basic Usage
To retrieve a monster by ID:
monster, err := monsters.Find(db, 1)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found monster: %s (level: %d, HP: %d)\n", monster.Name, monster.Level, monster.MaxHP)
To get all monsters:
allMonsters, err := monsters.All(db)
if err != nil {
log.Fatal(err)
}
for _, monster := range allMonsters {
fmt.Printf("Monster: %s\n", monster.Name)
}
To filter monsters by level:
level5Monsters, err := monsters.ByLevel(db, 5)
if err != nil {
log.Fatal(err)
}
To get monsters within a level range:
monsters1to10, err := monsters.ByLevelRange(db, 1, 10)
if err != nil {
log.Fatal(err)
}
To filter monsters by immunity type:
hurtImmune, err := monsters.ByImmunity(db, monsters.ImmuneHurt)
if err != nil {
log.Fatal(err)
}
# Creating Monsters with Builder Pattern
The package provides a fluent builder interface for creating new monsters:
monster, err := monsters.NewBuilder(db).
WithName("Fire Dragon").
WithMaxHP(500).
WithMaxDmg(100).
WithArmor(50).
WithLevel(25).
WithMaxExp(1000).
WithMaxGold(500).
WithImmunity(monsters.ImmuneHurt).
Create()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Created monster with ID: %d\n", monster.ID)
# Updating Monsters
Monsters can be modified and saved back to the database:
monster, _ := monsters.Find(db, 1)
monster.Name = "Enhanced Blue Slime"
monster.MaxHP = 10
monster.Level = 2
err := monster.Save()
if err != nil {
log.Fatal(err)
}
# Deleting Monsters
Monsters can be removed from the database:
monster, _ := monsters.Find(db, 1)
err := monster.Delete()
if err != nil {
log.Fatal(err)
}
# Monster Immunity Types
The package defines immunity type constants:
monsters.ImmuneNone = 0 // No immunity
monsters.ImmuneHurt = 1 // Immune to Hurt spells (Pain, Maim, Rend, Chaos)
monsters.ImmuneSleep = 2 // Immune to Sleep spells (Sleep, Dream, Nightmare)
Helper methods are available to check immunity:
if monster.IsHurtImmune() {
fmt.Println("This monster is immune to Hurt spells")
}
if monster.IsSleepImmune() {
fmt.Println("This monster is immune to Sleep spells")
}
if monster.HasImmunity() {
fmt.Printf("Monster has immunity: %s\n", monster.ImmunityName())
}
# Database Schema
The monsters table has the following structure:
CREATE TABLE monsters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
max_hp INTEGER NOT NULL DEFAULT 0,
max_dmg INTEGER NOT NULL DEFAULT 0,
armor INTEGER NOT NULL DEFAULT 0,
level INTEGER NOT NULL DEFAULT 0,
max_exp INTEGER NOT NULL DEFAULT 0,
max_gold INTEGER NOT NULL DEFAULT 0,
immune INTEGER NOT NULL DEFAULT 0
)
Where:
- id: Unique identifier
- name: Display name of the monster
- max_hp: Maximum hit points
- max_dmg: Maximum damage per attack
- armor: Armor class/defense rating
- level: Monster level (affects encounter chances)
- max_exp: Maximum experience points awarded when defeated
- max_gold: Maximum gold awarded when defeated
- immune: Immunity type (0=none, 1=hurt spells, 2=sleep spells)
# Monster Statistics
## Combat Stats
The core combat statistics define the monster's fighting capability:
fmt.Printf("HP: %d, Damage: %d, Armor: %d\n",
monster.MaxHP, monster.MaxDmg, monster.Armor)
## Rewards
Monsters provide experience and gold rewards when defeated:
fmt.Printf("Rewards: %d exp, %d gold\n",
monster.MaxExp, monster.MaxGold)
## Utility Methods
The package provides several utility methods for analyzing monsters:
// Difficulty rating based on stats relative to level
difficulty := monster.DifficultyRating()
fmt.Printf("Difficulty: %.2f\n", difficulty)
// Experience efficiency (exp per HP)
expEfficiency := monster.ExpPerHP()
fmt.Printf("Exp per HP: %.2f\n", expEfficiency)
// Gold efficiency (gold per HP)
goldEfficiency := monster.GoldPerHP()
fmt.Printf("Gold per HP: %.2f\n", goldEfficiency)
These methods help with:
- Balancing monster difficulty
- Identifying efficient farming targets
- Analyzing risk/reward ratios
# Level-Based Queries
Monsters are commonly filtered by level for encounter generation:
// Get all monsters at a specific level
level10Monsters, err := monsters.ByLevel(db, 10)
// Get monsters within a level range (inclusive)
earlyGameMonsters, err := monsters.ByLevelRange(db, 1, 5)
// All monsters are ordered by level, then ID for consistent results
# Immunity System
The immunity system affects spell combat mechanics:
// Check specific immunities
if monster.IsHurtImmune() {
// Hurt spells (Pain, Maim, Rend, Chaos) won't work
}
if monster.IsSleepImmune() {
// Sleep spells (Sleep, Dream, Nightmare) won't work
}
// Get all monsters with specific immunity
hurtImmuneMonsters, err := monsters.ByImmunity(db, monsters.ImmuneHurt)
# Error Handling
All functions return appropriate errors for common failure cases:
- Monster not found (Find returns error for non-existent IDs)
- Database connection issues
- Invalid operations (e.g., saving/deleting monsters without IDs)
*/
package monsters

View File

@ -4,21 +4,50 @@ import (
"fmt" "fmt"
"dk/internal/database" "dk/internal/database"
"dk/internal/helpers/scanner"
"zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite"
) )
// Monster represents a monster in the database // Monster represents a monster in the database
type Monster struct { type Monster struct {
ID int `json:"id"` ID int `db:"id" json:"id"`
Name string `json:"name"` Name string `db:"name" json:"name"`
MaxHP int `json:"max_hp"` MaxHP int `db:"max_hp" json:"max_hp"`
MaxDmg int `json:"max_dmg"` MaxDmg int `db:"max_dmg" json:"max_dmg"`
Armor int `json:"armor"` Armor int `db:"armor" json:"armor"`
Level int `json:"level"` Level int `db:"level" json:"level"`
MaxExp int `json:"max_exp"` MaxExp int `db:"max_exp" json:"max_exp"`
MaxGold int `json:"max_gold"` MaxGold int `db:"max_gold" json:"max_gold"`
Immune int `json:"immune"` Immune int `db:"immune" json:"immune"`
}
// New creates a new Monster with sensible defaults
func New() *Monster {
return &Monster{
Name: "",
MaxHP: 10, // Default HP
MaxDmg: 5, // Default damage
Armor: 0, // Default armor
Level: 1, // Default level
MaxExp: 10, // Default exp reward
MaxGold: 5, // Default gold reward
Immune: ImmuneNone, // No immunity by default
}
}
var monsterScanner = scanner.New[Monster]()
// monsterColumns returns the column list for monster queries
func monsterColumns() string {
return monsterScanner.Columns()
}
// scanMonster populates a Monster struct using the fast scanner
func scanMonster(stmt *sqlite.Stmt) *Monster {
monster := &Monster{}
monsterScanner.Scan(stmt, monster)
return monster
} }
// Immunity constants for monster immunity types // Immunity constants for monster immunity types
@ -30,19 +59,12 @@ const (
// Find retrieves a monster by ID // Find retrieves a monster by ID
func Find(id int) (*Monster, error) { func Find(id int) (*Monster, error) {
monster := &Monster{} var monster *Monster
query := `SELECT ` + monsterColumns() + ` FROM monsters WHERE id = ?`
query := "SELECT id, name, max_hp, max_dmg, armor, level, max_exp, max_gold, immune FROM monsters WHERE id = ?"
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
monster.ID = stmt.ColumnInt(0) monster = scanMonster(stmt)
monster.Name = stmt.ColumnText(1)
monster.MaxHP = stmt.ColumnInt(2)
monster.MaxDmg = stmt.ColumnInt(3)
monster.Armor = stmt.ColumnInt(4)
monster.Level = stmt.ColumnInt(5)
monster.MaxExp = stmt.ColumnInt(6)
monster.MaxGold = stmt.ColumnInt(7)
monster.Immune = stmt.ColumnInt(8)
return nil return nil
}, id) }, id)
@ -50,7 +72,7 @@ func Find(id int) (*Monster, error) {
return nil, fmt.Errorf("failed to find monster: %w", err) return nil, fmt.Errorf("failed to find monster: %w", err)
} }
if monster.ID == 0 { if monster == nil {
return nil, fmt.Errorf("monster with ID %d not found", id) return nil, fmt.Errorf("monster with ID %d not found", id)
} }
@ -61,19 +83,10 @@ func Find(id int) (*Monster, error) {
func All() ([]*Monster, error) { func All() ([]*Monster, error) {
var monsters []*Monster var monsters []*Monster
query := "SELECT id, name, max_hp, max_dmg, armor, level, max_exp, max_gold, immune FROM monsters ORDER BY level, id" query := `SELECT ` + monsterColumns() + ` FROM monsters ORDER BY level, id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
monster := &Monster{ monster := scanMonster(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
MaxHP: stmt.ColumnInt(2),
MaxDmg: stmt.ColumnInt(3),
Armor: stmt.ColumnInt(4),
Level: stmt.ColumnInt(5),
MaxExp: stmt.ColumnInt(6),
MaxGold: stmt.ColumnInt(7),
Immune: stmt.ColumnInt(8),
}
monsters = append(monsters, monster) monsters = append(monsters, monster)
return nil return nil
}) })
@ -89,19 +102,10 @@ func All() ([]*Monster, error) {
func ByLevel(level int) ([]*Monster, error) { func ByLevel(level int) ([]*Monster, error) {
var monsters []*Monster var monsters []*Monster
query := "SELECT id, name, max_hp, max_dmg, armor, level, max_exp, max_gold, immune FROM monsters WHERE level = ? ORDER BY id" query := `SELECT ` + monsterColumns() + ` FROM monsters WHERE level = ? ORDER BY id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
monster := &Monster{ monster := scanMonster(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
MaxHP: stmt.ColumnInt(2),
MaxDmg: stmt.ColumnInt(3),
Armor: stmt.ColumnInt(4),
Level: stmt.ColumnInt(5),
MaxExp: stmt.ColumnInt(6),
MaxGold: stmt.ColumnInt(7),
Immune: stmt.ColumnInt(8),
}
monsters = append(monsters, monster) monsters = append(monsters, monster)
return nil return nil
}, level) }, level)
@ -117,19 +121,10 @@ func ByLevel(level int) ([]*Monster, error) {
func ByLevelRange(minLevel, maxLevel int) ([]*Monster, error) { func ByLevelRange(minLevel, maxLevel int) ([]*Monster, error) {
var monsters []*Monster var monsters []*Monster
query := "SELECT id, name, max_hp, max_dmg, armor, level, max_exp, max_gold, immune FROM monsters WHERE level BETWEEN ? AND ? ORDER BY level, id" query := `SELECT ` + monsterColumns() + ` FROM monsters WHERE level BETWEEN ? AND ? ORDER BY level, id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
monster := &Monster{ monster := scanMonster(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
MaxHP: stmt.ColumnInt(2),
MaxDmg: stmt.ColumnInt(3),
Armor: stmt.ColumnInt(4),
Level: stmt.ColumnInt(5),
MaxExp: stmt.ColumnInt(6),
MaxGold: stmt.ColumnInt(7),
Immune: stmt.ColumnInt(8),
}
monsters = append(monsters, monster) monsters = append(monsters, monster)
return nil return nil
}, minLevel, maxLevel) }, minLevel, maxLevel)
@ -145,19 +140,10 @@ func ByLevelRange(minLevel, maxLevel int) ([]*Monster, error) {
func ByImmunity(immunityType int) ([]*Monster, error) { func ByImmunity(immunityType int) ([]*Monster, error) {
var monsters []*Monster var monsters []*Monster
query := "SELECT id, name, max_hp, max_dmg, armor, level, max_exp, max_gold, immune FROM monsters WHERE immune = ? ORDER BY level, id" query := `SELECT ` + monsterColumns() + ` FROM monsters WHERE immune = ? ORDER BY level, id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
monster := &Monster{ monster := scanMonster(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
MaxHP: stmt.ColumnInt(2),
MaxDmg: stmt.ColumnInt(3),
Armor: stmt.ColumnInt(4),
Level: stmt.ColumnInt(5),
MaxExp: stmt.ColumnInt(6),
MaxGold: stmt.ColumnInt(7),
Immune: stmt.ColumnInt(8),
}
monsters = append(monsters, monster) monsters = append(monsters, monster)
return nil return nil
}, immunityType) }, immunityType)
@ -179,6 +165,37 @@ func (m *Monster) Save() error {
return database.Exec(query, m.Name, m.MaxHP, m.MaxDmg, m.Armor, m.Level, m.MaxExp, m.MaxGold, m.Immune, m.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 {
if m.ID != 0 {
return fmt.Errorf("monster already has ID %d, use Save() to update", m.ID)
}
// 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 // Delete removes the monster from the database
func (m *Monster) Delete() error { func (m *Monster) Delete() error {
if m.ID == 0 { if m.ID == 0 {
@ -243,3 +260,27 @@ func (m *Monster) GoldPerHP() float64 {
} }
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

@ -1,82 +0,0 @@
package news
import (
"fmt"
"time"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating news posts
type Builder struct {
news *News
}
// NewBuilder creates a new news builder
func NewBuilder() *Builder {
return &Builder{
news: &News{
Posted: time.Now().Unix(), // Default to current time
},
}
}
// WithAuthor sets the author ID
func (b *Builder) WithAuthor(authorID int) *Builder {
b.news.Author = authorID
return b
}
// WithContent sets the news content
func (b *Builder) WithContent(content string) *Builder {
b.news.Content = content
return b
}
// WithPosted sets the posted timestamp
func (b *Builder) WithPosted(posted int64) *Builder {
b.news.Posted = posted
return b
}
// WithPostedTime sets the posted timestamp from a time.Time
func (b *Builder) WithPostedTime(t time.Time) *Builder {
b.news.Posted = t.Unix()
return b
}
// Create saves the news post to the database and returns the created news with ID
func (b *Builder) Create() (*News, error) {
// Use a transaction to ensure we can get the ID
var news *News
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO news (author, posted, content)
VALUES (?, ?, ?)`
if err := tx.Exec(query, b.news.Author, b.news.Posted, b.news.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)
}
b.news.ID = id
news = b.news
return nil
})
if err != nil {
return nil, err
}
return news, nil
}

View File

@ -1,349 +0,0 @@
/*
Package news is the active record implementation for news posts in the game.
# Basic Usage
To retrieve a news post by ID:
newsPost, err := news.Find(db, 1)
if err != nil {
log.Fatal(err)
}
fmt.Printf("News: %s (by user %d)\n", newsPost.Content, newsPost.Author)
To get all news posts:
allNews, err := news.All(db)
if err != nil {
log.Fatal(err)
}
for _, post := range allNews {
fmt.Printf("News: %s\n", post.Content)
}
To get recent news posts:
recentNews, err := news.Recent(db, 10)
if err != nil {
log.Fatal(err)
}
To filter news by author:
authorPosts, err := news.ByAuthor(db, userID)
if err != nil {
log.Fatal(err)
}
To get news since a specific time:
yesterday := time.Now().AddDate(0, 0, -1).Unix()
recentNews, err := news.Since(db, yesterday)
if err != nil {
log.Fatal(err)
}
# Creating News with Builder Pattern
The package provides a fluent builder interface for creating new news posts:
newsPost, err := news.NewBuilder(db).
WithAuthor(userID).
WithContent("Welcome to the new update! Many exciting features await.").
WithPostedTime(time.Now()).
Create()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Created news post with ID: %d\n", newsPost.ID)
The builder automatically sets the current time if no posted time is specified:
newsPost, err := news.NewBuilder(db).
WithAuthor(adminID).
WithContent("Server maintenance scheduled for tonight.").
Create() // Uses current timestamp
# Updating News
News posts can be modified and saved back to the database:
newsPost, _ := news.Find(db, 1)
newsPost.Content = "Updated: Server maintenance completed successfully."
err := newsPost.Save()
if err != nil {
log.Fatal(err)
}
# Deleting News
News posts can be removed from the database:
newsPost, _ := news.Find(db, 1)
err := newsPost.Delete()
if err != nil {
log.Fatal(err)
}
# Database Schema
The news table has the following structure:
CREATE TABLE news (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author INTEGER NOT NULL,
posted INTEGER NOT NULL DEFAULT (unixepoch()),
content TEXT NOT NULL
)
Where:
- id: Unique identifier for the news post
- author: User ID of the author who created the post
- posted: Unix timestamp when the post was created
- content: The text content of the news post
# Time-Based Queries
## Recent News
Get the most recent news posts:
// Get 5 most recent posts
latestNews, err := news.Recent(db, 5)
if err != nil {
log.Fatal(err)
}
for _, post := range latestNews {
fmt.Printf("Posted %s: %s\n",
post.PostedTime().Format("2006-01-02"),
post.Preview(50))
}
## News Since Timestamp
Get news posts since a specific time:
// Get news from the last week
weekAgo := time.Now().AddDate(0, 0, -7).Unix()
weeklyNews, err := news.Since(db, weekAgo)
if err != nil {
log.Fatal(err)
}
## News Between Timestamps
Get news posts within a time range:
// Get news from last month
start := time.Now().AddDate(0, -1, 0).Unix()
end := time.Now().Unix()
monthlyNews, err := news.Between(db, start, end)
if err != nil {
log.Fatal(err)
}
# Content Management
## Content Analysis
The package provides utilities for analyzing news content:
wordCount := newsPost.WordCount()
fmt.Printf("Post contains %d words\n", wordCount)
preview := newsPost.Preview(100)
fmt.Printf("Preview: %s\n", preview)
## Author Management
Check authorship and manage posts by author:
if newsPost.IsAuthor(currentUserID) {
fmt.Println("You can edit this post")
}
// Get all posts by a specific author
authorPosts, err := news.ByAuthor(db, authorID)
fmt.Printf("Author has written %d posts\n", len(authorPosts))
# Time Utilities
## Working with Timestamps
The package provides convenient time utilities:
// Get posting time as time.Time
postTime := newsPost.PostedTime()
fmt.Printf("Posted on: %s\n", postTime.Format("January 2, 2006"))
// Set posting time from time.Time
newsPost.SetPostedTime(time.Now().Add(-2 * time.Hour))
// Check if post is recent (within 24 hours)
if newsPost.IsRecent() {
fmt.Println("This is a recent post")
}
// Get age of the post
age := newsPost.Age()
fmt.Printf("Posted %v ago\n", age)
## Time-Based Filtering
Find posts based on recency:
allNews, _ := news.All(db)
recentPosts := make([]*news.News, 0)
for _, post := range allNews {
if post.IsRecent() {
recentPosts = append(recentPosts, post)
}
}
fmt.Printf("Found %d recent posts\n", len(recentPosts))
# Administrative Features
## Moderation
Administrators can manage news posts:
// Get all posts by a user for moderation
suspiciousPosts, err := news.ByAuthor(db, reportedUserID)
for _, post := range suspiciousPosts {
if post.WordCount() < 5 {
fmt.Printf("Short post flagged: %s\n", post.Preview(50))
}
}
## Content Guidelines
Use content analysis for moderation:
if newsPost.WordCount() > 1000 {
fmt.Println("Warning: Very long post")
}
if len(newsPost.Content) < 10 {
fmt.Println("Warning: Very short post")
}
# Display and Formatting
## News Feeds
Display news in chronological order:
recentNews, _ := news.Recent(db, 20)
fmt.Println("=== Latest News ===")
for _, post := range recentNews {
age := post.Age()
var ageStr string
if age < time.Hour {
ageStr = fmt.Sprintf("%d minutes ago", int(age.Minutes()))
} else if age < 24*time.Hour {
ageStr = fmt.Sprintf("%d hours ago", int(age.Hours()))
} else {
ageStr = fmt.Sprintf("%d days ago", int(age.Hours()/24))
}
fmt.Printf("[%s] %s\n", ageStr, post.Preview(80))
}
## Preview Generation
Generate previews for different contexts:
// Short preview for mobile
mobilePreview := newsPost.Preview(30)
// Medium preview for web cards
cardPreview := newsPost.Preview(100)
// Long preview for detailed view
detailPreview := newsPost.Preview(200)
# Performance Considerations
## Query Optimization
All time-based queries are optimized with proper indexing expectations:
// Queries are ordered by posted timestamp for efficient retrieval
latestNews, _ := news.All(db) // ORDER BY posted DESC, id DESC
// Author queries include time ordering
authorPosts, _ := news.ByAuthor(db, userID) // ORDER BY posted DESC, id DESC
## Batch Operations
For processing multiple posts efficiently:
// Process posts in batches
batchSize := 100
allNews, _ := news.All(db)
for i := 0; i < len(allNews); i += batchSize {
end := i + batchSize
if end > len(allNews) {
end = len(allNews)
}
batch := allNews[i:end]
// Process batch...
}
# Error Handling
All functions return appropriate errors for common failure cases:
- News post not found (Find returns error for non-existent IDs)
- Database connection issues
- Invalid operations (e.g., saving/deleting news posts without IDs)
- Time range query errors
# Integration Patterns
## User Integration
Connect with user management:
// Example: Get news by username (assuming user lookup)
user := getUserByName("admin")
adminNews, err := news.ByAuthor(db, user.ID)
## Notification System
Use for activity feeds:
// Get user's activity since last login
lastLogin := getUserLastLogin(userID)
newsSinceLogin, err := news.Since(db, lastLogin)
if len(newsSinceLogin) > 0 {
fmt.Printf("You have %d new posts since your last visit\n", len(newsSinceLogin))
}
## Archive Management
Implement content archiving:
// Archive old posts (older than 1 year)
oneYearAgo := time.Now().AddDate(-1, 0, 0).Unix()
oldPosts, _ := news.Since(db, 0) // Get all posts
for _, post := range oldPosts {
if post.Posted < oneYearAgo {
// Archive or delete old post
post.Delete()
}
}
*/
package news

View File

@ -2,7 +2,9 @@ package news
import ( import (
"dk/internal/database" "dk/internal/database"
"dk/internal/helpers/scanner"
"fmt" "fmt"
"strings"
"time" "time"
"zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite"
@ -10,22 +12,43 @@ import (
// News represents a news post in the database // News represents a news post in the database
type News struct { type News struct {
ID int `json:"id"` ID int `db:"id" json:"id"`
Author int `json:"author"` Author int `db:"author" json:"author"`
Posted int64 `json:"posted"` Posted int64 `db:"posted" json:"posted"`
Content string `json:"content"` Content string `db:"content" json:"content"`
}
// New creates a new News with sensible defaults
func New() *News {
return &News{
Author: 0, // No author by default
Posted: time.Now().Unix(), // Current time
Content: "", // Empty content
}
}
var newsScanner = scanner.New[News]()
// newsColumns returns the column list for news queries
func newsColumns() string {
return newsScanner.Columns()
}
// scanNews populates a News struct using the fast scanner
func scanNews(stmt *sqlite.Stmt) *News {
news := &News{}
newsScanner.Scan(stmt, news)
return news
} }
// Find retrieves a news post by ID // Find retrieves a news post by ID
func Find(id int) (*News, error) { func Find(id int) (*News, error) {
news := &News{} var news *News
query := `SELECT ` + newsColumns() + ` FROM news WHERE id = ?`
query := "SELECT id, author, posted, content FROM news WHERE id = ?"
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
news.ID = stmt.ColumnInt(0) news = scanNews(stmt)
news.Author = stmt.ColumnInt(1)
news.Posted = stmt.ColumnInt64(2)
news.Content = stmt.ColumnText(3)
return nil return nil
}, id) }, id)
@ -33,7 +56,7 @@ func Find(id int) (*News, error) {
return nil, fmt.Errorf("failed to find news: %w", err) return nil, fmt.Errorf("failed to find news: %w", err)
} }
if news.ID == 0 { if news == nil {
return nil, fmt.Errorf("news with ID %d not found", id) return nil, fmt.Errorf("news with ID %d not found", id)
} }
@ -44,14 +67,10 @@ func Find(id int) (*News, error) {
func All() ([]*News, error) { func All() ([]*News, error) {
var newsPosts []*News var newsPosts []*News
query := "SELECT id, author, posted, content FROM news ORDER BY posted DESC, id DESC" query := `SELECT ` + newsColumns() + ` FROM news ORDER BY posted DESC, id DESC`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
news := &News{ news := scanNews(stmt)
ID: stmt.ColumnInt(0),
Author: stmt.ColumnInt(1),
Posted: stmt.ColumnInt64(2),
Content: stmt.ColumnText(3),
}
newsPosts = append(newsPosts, news) newsPosts = append(newsPosts, news)
return nil return nil
}) })
@ -67,14 +86,10 @@ func All() ([]*News, error) {
func ByAuthor(authorID int) ([]*News, error) { func ByAuthor(authorID int) ([]*News, error) {
var newsPosts []*News var newsPosts []*News
query := "SELECT id, author, posted, content FROM news WHERE author = ? ORDER BY posted DESC, id DESC" query := `SELECT ` + newsColumns() + ` FROM news WHERE author = ? ORDER BY posted DESC, id DESC`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
news := &News{ news := scanNews(stmt)
ID: stmt.ColumnInt(0),
Author: stmt.ColumnInt(1),
Posted: stmt.ColumnInt64(2),
Content: stmt.ColumnText(3),
}
newsPosts = append(newsPosts, news) newsPosts = append(newsPosts, news)
return nil return nil
}, authorID) }, authorID)
@ -90,14 +105,10 @@ func ByAuthor(authorID int) ([]*News, error) {
func Recent(limit int) ([]*News, error) { func Recent(limit int) ([]*News, error) {
var newsPosts []*News var newsPosts []*News
query := "SELECT id, author, posted, content FROM news ORDER BY posted DESC, id DESC LIMIT ?" query := `SELECT ` + newsColumns() + ` FROM news ORDER BY posted DESC, id DESC LIMIT ?`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
news := &News{ news := scanNews(stmt)
ID: stmt.ColumnInt(0),
Author: stmt.ColumnInt(1),
Posted: stmt.ColumnInt64(2),
Content: stmt.ColumnText(3),
}
newsPosts = append(newsPosts, news) newsPosts = append(newsPosts, news)
return nil return nil
}, limit) }, limit)
@ -113,14 +124,10 @@ func Recent(limit int) ([]*News, error) {
func Since(since int64) ([]*News, error) { func Since(since int64) ([]*News, error) {
var newsPosts []*News var newsPosts []*News
query := "SELECT id, author, posted, content FROM news WHERE posted >= ? ORDER BY posted DESC, id DESC" query := `SELECT ` + newsColumns() + ` FROM news WHERE posted >= ? ORDER BY posted DESC, id DESC`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
news := &News{ news := scanNews(stmt)
ID: stmt.ColumnInt(0),
Author: stmt.ColumnInt(1),
Posted: stmt.ColumnInt64(2),
Content: stmt.ColumnText(3),
}
newsPosts = append(newsPosts, news) newsPosts = append(newsPosts, news)
return nil return nil
}, since) }, since)
@ -136,14 +143,10 @@ func Since(since int64) ([]*News, error) {
func Between(start, end int64) ([]*News, error) { func Between(start, end int64) ([]*News, error) {
var newsPosts []*News var newsPosts []*News
query := "SELECT id, author, posted, content FROM news WHERE posted >= ? AND posted <= ? ORDER BY posted DESC, id DESC" query := `SELECT ` + newsColumns() + ` FROM news WHERE posted >= ? AND posted <= ? ORDER BY posted DESC, id DESC`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
news := &News{ news := scanNews(stmt)
ID: stmt.ColumnInt(0),
Author: stmt.ColumnInt(1),
Posted: stmt.ColumnInt64(2),
Content: stmt.ColumnText(3),
}
newsPosts = append(newsPosts, news) newsPosts = append(newsPosts, news)
return nil return nil
}, start, end) }, start, end)
@ -165,6 +168,37 @@ func (n *News) Save() error {
return database.Exec(query, n.Author, n.Posted, n.Content, n.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 {
if n.ID != 0 {
return fmt.Errorf("news already has ID %d, use Save() to update", n.ID)
}
// 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 // Delete removes the news post from the database
func (n *News) Delete() error { func (n *News) Delete() error {
if n.ID == 0 { if n.ID == 0 {
@ -240,3 +274,56 @@ func (n *News) WordCount() int {
return words return words
} }
// Length returns the character length of the content
func (n *News) Length() int {
return len(n.Content)
}
// Contains returns true if the content contains the given term (case-insensitive)
func (n *News) Contains(term string) bool {
return strings.Contains(strings.ToLower(n.Content), strings.ToLower(term))
}
// IsEmpty returns true if the content is empty or whitespace-only
func (n *News) IsEmpty() bool {
return strings.TrimSpace(n.Content) == ""
}
// Search retrieves news posts containing the search term in content
func Search(term string) ([]*News, error) {
var newsPosts []*News
query := `SELECT ` + newsColumns() + ` FROM news WHERE LOWER(content) LIKE LOWER(?) ORDER BY posted DESC, id DESC`
searchTerm := "%" + term + "%"
err := database.Query(query, func(stmt *sqlite.Stmt) error {
news := scanNews(stmt)
newsPosts = append(newsPosts, news)
return nil
}, searchTerm)
if err != nil {
return nil, fmt.Errorf("failed to search news: %w", err)
}
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(),
"WordCount": n.WordCount(),
"Length": n.Length(),
"IsEmpty": n.IsEmpty(),
}
}

View File

@ -1,85 +0,0 @@
package spells
import (
"dk/internal/database"
"fmt"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating spells
type Builder struct {
spell *Spell
}
// NewBuilder creates a new spell builder
func NewBuilder() *Builder {
return &Builder{
spell: &Spell{},
}
}
// WithName sets the spell name
func (b *Builder) WithName(name string) *Builder {
b.spell.Name = name
return b
}
// WithMP sets the spell's mana point cost
func (b *Builder) WithMP(mp int) *Builder {
b.spell.MP = mp
return b
}
// WithAttribute sets the spell's attribute (power/effectiveness)
func (b *Builder) WithAttribute(attribute int) *Builder {
b.spell.Attribute = attribute
return b
}
// WithType sets the spell type
func (b *Builder) WithType(spellType int) *Builder {
b.spell.Type = spellType
return b
}
// Create saves the spell to the database and returns it
func (b *Builder) Create() (*Spell, error) {
// Use a transaction to ensure we can get the ID
var spell *Spell
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO spells (name, mp, attribute, type)
VALUES (?, ?, ?, ?)`
if err := tx.Exec(query, b.spell.Name, b.spell.MP, b.spell.Attribute, b.spell.Type); err != nil {
return fmt.Errorf("failed to insert spell: %w", err)
}
// Get the last inserted ID within the same transaction
var lastID int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
lastID = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get last insert ID: %w", err)
}
// Create the spell with the ID
spell = &Spell{
ID: lastID,
Name: b.spell.Name,
MP: b.spell.MP,
Attribute: b.spell.Attribute,
Type: b.spell.Type,
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to create spell: %w", err)
}
return spell, nil
}

View File

@ -1,267 +0,0 @@
/*
Package spells is the active record implementation for spells in the game.
# Basic Usage
To retrieve a spell by ID:
spell, err := spells.Find(db, 1)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found spell: %s (MP: %d, Power: %d)\n", spell.Name, spell.MP, spell.Attribute)
To get all spells:
allSpells, err := spells.All(db)
if err != nil {
log.Fatal(err)
}
for _, spell := range allSpells {
fmt.Printf("Spell: %s\n", spell.Name)
}
To find a spell by name:
heal, err := spells.ByName(db, "Heal")
if err != nil {
log.Fatal(err)
}
To filter spells by type:
healingSpells, err := spells.ByType(db, spells.TypeHealing)
if err != nil {
log.Fatal(err)
}
To get spells within MP budget:
affordableSpells, err := spells.ByMaxMP(db, 10)
if err != nil {
log.Fatal(err)
}
To get spells of specific type within MP budget:
cheapHurtSpells, err := spells.ByTypeAndMaxMP(db, spells.TypeHurt, 15)
if err != nil {
log.Fatal(err)
}
# Creating Spells with Builder Pattern
The package provides a fluent builder interface for creating new spells:
spell, err := spells.NewBuilder(db).
WithName("Lightning Bolt").
WithMP(25).
WithAttribute(70).
WithType(spells.TypeHurt).
Create()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Created spell with ID: %d\n", spell.ID)
# Updating Spells
Spells can be modified and saved back to the database:
spell, _ := spells.Find(db, 1)
spell.Name = "Greater Heal"
spell.MP = 8
spell.Attribute = 20
err := spell.Save()
if err != nil {
log.Fatal(err)
}
# Deleting Spells
Spells can be removed from the database:
spell, _ := spells.Find(db, 1)
err := spell.Delete()
if err != nil {
log.Fatal(err)
}
# Spell Types
The package defines spell type constants:
spells.TypeHealing = 1 // Healing spells (Heal, Revive, Life, Breath, Gaia)
spells.TypeHurt = 2 // Hurt spells (Hurt, Pain, Maim, Rend, Chaos)
spells.TypeSleep = 3 // Sleep spells (Sleep, Dream, Nightmare)
spells.TypeAttackBoost = 4 // Attack boost spells (Craze, Rage, Fury)
spells.TypeDefenseBoost = 5 // Defense boost spells (Ward, Fend, Barrier)
Helper methods are available to check spell types:
if spell.IsHealing() {
fmt.Println("This spell heals the caster")
}
if spell.IsHurt() {
fmt.Println("This spell damages enemies")
}
if spell.IsSleep() {
fmt.Println("This spell puts enemies to sleep")
}
if spell.IsAttackBoost() {
fmt.Println("This spell boosts attack power")
}
if spell.IsDefenseBoost() {
fmt.Println("This spell boosts defense")
}
fmt.Printf("Spell type: %s\n", spell.TypeName())
# Database Schema
The spells table has the following structure:
CREATE TABLE spells (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
mp INTEGER NOT NULL DEFAULT 0,
attribute INTEGER NOT NULL DEFAULT 0,
type INTEGER NOT NULL DEFAULT 0
)
Where:
- id: Unique identifier
- name: Display name of the spell
- mp: Mana points required to cast the spell
- attribute: Power/effectiveness of the spell
- type: Spell type (1-5 as defined above)
# Spell Mechanics
## Mana Point System
All spells require MP to cast:
if spell.CanCast(player.CurrentMP) {
fmt.Printf("You can cast %s\n", spell.Name)
} else {
fmt.Printf("Not enough MP to cast %s (need %d, have %d)\n",
spell.Name, spell.MP, player.CurrentMP)
}
## Spell Efficiency
Calculate efficiency as attribute per MP cost:
efficiency := spell.Efficiency()
fmt.Printf("%s efficiency: %.2f power per MP\n", spell.Name, efficiency)
This helps players choose the most cost-effective spells.
## Offensive vs Support Spells
Spells are categorized by their primary purpose:
if spell.IsOffensive() {
// Hurt and Sleep spells - used against enemies
fmt.Println("Offensive spell - targets enemies")
}
if spell.IsSupport() {
// Healing and boost spells - used to help player
fmt.Println("Support spell - helps the caster")
}
# Spell Categories
## Healing Spells (Type 1)
Restore hit points to the caster:
- Heal: Basic healing
- Revive: Moderate healing
- Life: Strong healing
- Breath: Very strong healing
- Gaia: Ultimate healing
## Hurt Spells (Type 2)
Deal damage to enemies:
- Hurt: Basic damage
- Pain: Moderate damage
- Maim: Strong damage
- Rend: Very strong damage
- Chaos: Ultimate damage
Note: Some monsters have immunity to Hurt spells.
## Sleep Spells (Type 3)
Put enemies to sleep, preventing them from attacking:
- Sleep: Basic sleep effect
- Dream: Moderate sleep effect
- Nightmare: Strong sleep effect
Note: Some monsters have immunity to Sleep spells.
## Attack Boost Spells (Type 4)
Temporarily increase the caster's attack power:
- Craze: Basic attack boost
- Rage: Moderate attack boost
- Fury: Strong attack boost
## Defense Boost Spells (Type 5)
Temporarily increase the caster's defense:
- Ward: Basic defense boost
- Fend: Moderate defense boost
- Barrier: Strong defense boost
# Query Patterns
## Finding Castable Spells
Get spells a player can cast with their current MP:
currentMP := 25
castableSpells, err := spells.ByMaxMP(db, currentMP)
// Filter by type and MP budget
castableHurtSpells, err := spells.ByTypeAndMaxMP(db, spells.TypeHurt, currentMP)
## Spell Progression
Players typically learn spells in order of power/cost:
// Get all healing spells ordered by cost
healingSpells, err := spells.ByType(db, spells.TypeHealing)
// Results are ordered by MP cost automatically
## Combat Spell Selection
For combat AI or recommendations:
// Get most efficient hurt spells within budget
hurtSpells, err := spells.ByTypeAndMaxMP(db, spells.TypeHurt, availableMP)
// Find most efficient spell
var bestSpell *spells.Spell
var bestEfficiency float64
for _, spell := range hurtSpells {
if eff := spell.Efficiency(); eff > bestEfficiency {
bestEfficiency = eff
bestSpell = spell
}
}
# Error Handling
All functions return appropriate errors for common failure cases:
- Spell not found (Find/ByName returns error for non-existent spells)
- Database connection issues
- Invalid operations (e.g., saving/deleting spells without IDs)
*/
package spells

View File

@ -4,17 +4,42 @@ import (
"fmt" "fmt"
"dk/internal/database" "dk/internal/database"
"dk/internal/helpers/scanner"
"zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite"
) )
// Spell represents a spell in the database // Spell represents a spell in the database
type Spell struct { type Spell struct {
ID int `json:"id"` ID int `db:"id" json:"id"`
Name string `json:"name"` Name string `db:"name" json:"name"`
MP int `json:"mp"` MP int `db:"mp" json:"mp"`
Attribute int `json:"attribute"` Attribute int `db:"attribute" json:"attribute"`
Type int `json:"type"` Type int `db:"type" json:"type"`
}
// New creates a new Spell with sensible defaults
func New() *Spell {
return &Spell{
Name: "",
MP: 5, // Default MP cost
Attribute: 10, // Default attribute value
Type: TypeHealing, // Default to healing spell
}
}
var spellScanner = scanner.New[Spell]()
// spellColumns returns the column list for spell queries
func spellColumns() string {
return spellScanner.Columns()
}
// scanSpell populates a Spell struct using the fast scanner
func scanSpell(stmt *sqlite.Stmt) *Spell {
spell := &Spell{}
spellScanner.Scan(stmt, spell)
return spell
} }
// SpellType constants for spell types // SpellType constants for spell types
@ -28,15 +53,12 @@ const (
// Find retrieves a spell by ID // Find retrieves a spell by ID
func Find(id int) (*Spell, error) { func Find(id int) (*Spell, error) {
spell := &Spell{} var spell *Spell
query := `SELECT ` + spellColumns() + ` FROM spells WHERE id = ?`
query := "SELECT id, name, mp, attribute, type FROM spells WHERE id = ?"
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
spell.ID = stmt.ColumnInt(0) spell = scanSpell(stmt)
spell.Name = stmt.ColumnText(1)
spell.MP = stmt.ColumnInt(2)
spell.Attribute = stmt.ColumnInt(3)
spell.Type = stmt.ColumnInt(4)
return nil return nil
}, id) }, id)
@ -44,7 +66,7 @@ func Find(id int) (*Spell, error) {
return nil, fmt.Errorf("failed to find spell: %w", err) return nil, fmt.Errorf("failed to find spell: %w", err)
} }
if spell.ID == 0 { if spell == nil {
return nil, fmt.Errorf("spell with ID %d not found", id) return nil, fmt.Errorf("spell with ID %d not found", id)
} }
@ -55,15 +77,10 @@ func Find(id int) (*Spell, error) {
func All() ([]*Spell, error) { func All() ([]*Spell, error) {
var spells []*Spell var spells []*Spell
query := "SELECT id, name, mp, attribute, type FROM spells ORDER BY type, mp, id" query := `SELECT ` + spellColumns() + ` FROM spells ORDER BY type, mp, id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
spell := &Spell{ spell := scanSpell(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
MP: stmt.ColumnInt(2),
Attribute: stmt.ColumnInt(3),
Type: stmt.ColumnInt(4),
}
spells = append(spells, spell) spells = append(spells, spell)
return nil return nil
}) })
@ -79,15 +96,10 @@ func All() ([]*Spell, error) {
func ByType(spellType int) ([]*Spell, error) { func ByType(spellType int) ([]*Spell, error) {
var spells []*Spell var spells []*Spell
query := "SELECT id, name, mp, attribute, type FROM spells WHERE type = ? ORDER BY mp, id" query := `SELECT ` + spellColumns() + ` FROM spells WHERE type = ? ORDER BY mp, id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
spell := &Spell{ spell := scanSpell(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
MP: stmt.ColumnInt(2),
Attribute: stmt.ColumnInt(3),
Type: stmt.ColumnInt(4),
}
spells = append(spells, spell) spells = append(spells, spell)
return nil return nil
}, spellType) }, spellType)
@ -103,15 +115,10 @@ func ByType(spellType int) ([]*Spell, error) {
func ByMaxMP(maxMP int) ([]*Spell, error) { func ByMaxMP(maxMP int) ([]*Spell, error) {
var spells []*Spell var spells []*Spell
query := "SELECT id, name, mp, attribute, type FROM spells WHERE mp <= ? ORDER BY type, mp, id" query := `SELECT ` + spellColumns() + ` FROM spells WHERE mp <= ? ORDER BY type, mp, id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
spell := &Spell{ spell := scanSpell(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
MP: stmt.ColumnInt(2),
Attribute: stmt.ColumnInt(3),
Type: stmt.ColumnInt(4),
}
spells = append(spells, spell) spells = append(spells, spell)
return nil return nil
}, maxMP) }, maxMP)
@ -127,15 +134,10 @@ func ByMaxMP(maxMP int) ([]*Spell, error) {
func ByTypeAndMaxMP(spellType, maxMP int) ([]*Spell, error) { func ByTypeAndMaxMP(spellType, maxMP int) ([]*Spell, error) {
var spells []*Spell var spells []*Spell
query := "SELECT id, name, mp, attribute, type FROM spells WHERE type = ? AND mp <= ? ORDER BY mp, id" query := `SELECT ` + spellColumns() + ` FROM spells WHERE type = ? AND mp <= ? ORDER BY mp, id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
spell := &Spell{ spell := scanSpell(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
MP: stmt.ColumnInt(2),
Attribute: stmt.ColumnInt(3),
Type: stmt.ColumnInt(4),
}
spells = append(spells, spell) spells = append(spells, spell)
return nil return nil
}, spellType, maxMP) }, spellType, maxMP)
@ -149,15 +151,12 @@ func ByTypeAndMaxMP(spellType, maxMP int) ([]*Spell, error) {
// ByName retrieves a spell by name (case-insensitive) // ByName retrieves a spell by name (case-insensitive)
func ByName(name string) (*Spell, error) { func ByName(name string) (*Spell, error) {
spell := &Spell{} var spell *Spell
query := `SELECT ` + spellColumns() + ` FROM spells WHERE LOWER(name) = LOWER(?) LIMIT 1`
query := "SELECT id, name, mp, attribute, type FROM spells WHERE LOWER(name) = LOWER(?) LIMIT 1"
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
spell.ID = stmt.ColumnInt(0) spell = scanSpell(stmt)
spell.Name = stmt.ColumnText(1)
spell.MP = stmt.ColumnInt(2)
spell.Attribute = stmt.ColumnInt(3)
spell.Type = stmt.ColumnInt(4)
return nil return nil
}, name) }, name)
@ -165,7 +164,7 @@ func ByName(name string) (*Spell, error) {
return nil, fmt.Errorf("failed to find spell by name: %w", err) return nil, fmt.Errorf("failed to find spell by name: %w", err)
} }
if spell.ID == 0 { if spell == nil {
return nil, fmt.Errorf("spell with name '%s' not found", name) return nil, fmt.Errorf("spell with name '%s' not found", name)
} }
@ -182,6 +181,37 @@ func (s *Spell) Save() error {
return database.Exec(query, s.Name, s.MP, s.Attribute, s.Type, s.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 {
if s.ID != 0 {
return fmt.Errorf("spell already has ID %d, use Save() to update", s.ID)
}
// 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 // Delete removes the spell from the database
func (s *Spell) Delete() error { func (s *Spell) Delete() error {
if s.ID == 0 { if s.ID == 0 {
@ -257,3 +287,25 @@ func (s *Spell) IsOffensive() bool {
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

@ -1,111 +0,0 @@
package towns
import (
"fmt"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating towns
type Builder struct {
town *Town
}
// NewBuilder creates a new town builder
func NewBuilder() *Builder {
return &Builder{
town: &Town{},
}
}
// WithName sets the town name
func (b *Builder) WithName(name string) *Builder {
b.town.Name = name
return b
}
// WithX sets the X coordinate
func (b *Builder) WithX(x int) *Builder {
b.town.X = x
return b
}
// WithY sets the Y coordinate
func (b *Builder) WithY(y int) *Builder {
b.town.Y = y
return b
}
// WithCoordinates sets both X and Y coordinates
func (b *Builder) WithCoordinates(x, y int) *Builder {
b.town.X = x
b.town.Y = y
return b
}
// WithInnCost sets the inn cost
func (b *Builder) WithInnCost(cost int) *Builder {
b.town.InnCost = cost
return b
}
// WithMapCost sets the map cost
func (b *Builder) WithMapCost(cost int) *Builder {
b.town.MapCost = cost
return b
}
// WithTPCost sets the teleport cost
func (b *Builder) WithTPCost(cost int) *Builder {
b.town.TPCost = cost
return b
}
// WithShopList sets the shop list as a comma-separated string
func (b *Builder) WithShopList(shopList string) *Builder {
b.town.ShopList = shopList
return b
}
// WithShopItems sets the shop items from a slice of item IDs
func (b *Builder) WithShopItems(items []string) *Builder {
b.town.SetShopItems(items)
return b
}
// Create saves the town to the database and returns the created town with ID
func (b *Builder) Create() (*Town, error) {
// Use a transaction to ensure we can get the ID
var town *Town
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, b.town.Name, b.town.X, b.town.Y,
b.town.InnCost, b.town.MapCost, b.town.TPCost, b.town.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)
}
b.town.ID = id
town = b.town
return nil
})
if err != nil {
return nil, err
}
return town, nil
}

View File

@ -1,295 +0,0 @@
/*
Package towns is the active record implementation for towns in the game.
# Basic Usage
To retrieve a town by ID:
town, err := towns.Find(db, 1)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found town: %s at (%d,%d)\n", town.Name, town.X, town.Y)
To get all towns:
allTowns, err := towns.All(db)
if err != nil {
log.Fatal(err)
}
for _, town := range allTowns {
fmt.Printf("Town: %s\n", town.Name)
}
To find a town by name:
midworld, err := towns.ByName(db, "Midworld")
if err != nil {
log.Fatal(err)
}
To filter towns by affordability:
cheapInns, err := towns.ByMaxInnCost(db, 25)
if err != nil {
log.Fatal(err)
}
affordableTP, err := towns.ByMaxTPCost(db, 50)
if err != nil {
log.Fatal(err)
}
To find nearby towns:
nearbyTowns, err := towns.ByDistance(db, playerX, playerY, 100)
if err != nil {
log.Fatal(err)
}
# Creating Towns with Builder Pattern
The package provides a fluent builder interface for creating new towns:
town, err := towns.NewBuilder(db).
WithName("New Settlement").
WithCoordinates(150, -75).
WithInnCost(35).
WithMapCost(125).
WithTPCost(40).
WithShopItems([]string{"1", "5", "10", "20"}).
Create()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Created town with ID: %d\n", town.ID)
# Updating Towns
Towns can be modified and saved back to the database:
town, _ := towns.Find(db, 1)
town.Name = "Enhanced Midworld"
town.InnCost = 8
town.MapCost = 10
err := town.Save()
if err != nil {
log.Fatal(err)
}
# Deleting Towns
Towns can be removed from the database:
town, _ := towns.Find(db, 1)
err := town.Delete()
if err != nil {
log.Fatal(err)
}
# Database Schema
The towns table has the following structure:
CREATE TABLE towns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
x INTEGER NOT NULL DEFAULT 0,
y INTEGER NOT NULL DEFAULT 0,
inn_cost INTEGER NOT NULL DEFAULT 0,
map_cost INTEGER NOT NULL DEFAULT 0,
tp_cost INTEGER NOT NULL DEFAULT 0,
shop_list TEXT NOT NULL DEFAULT ''
)
Where:
- id: Unique identifier
- name: Display name of the town
- x, y: World coordinates for the town location
- inn_cost: Cost in gold to rest at the inn
- map_cost: Cost in gold to buy the world map from this town
- tp_cost: Cost in gold to teleport to this town from elsewhere
- shop_list: Comma-separated list of item IDs sold in the town shop
# Town Economics
## Inn System
Towns provide rest services through inns:
if town.CanAffordInn(playerGold) {
fmt.Printf("You can rest at %s for %d gold\n", town.Name, town.InnCost)
}
Inn costs typically increase with town size and distance from starting area.
## Map Purchase
Maps can be bought from certain towns:
if town.MapCost > 0 && town.CanAffordMap(playerGold) {
fmt.Printf("Map available for %d gold at %s\n", town.MapCost, town.Name)
}
Not all towns sell maps (cost = 0 means unavailable).
## Teleportation Network
Towns with teleport access allow fast travel:
if town.TPCost > 0 && town.CanAffordTeleport(playerGold) {
fmt.Printf("Can teleport to %s for %d gold\n", town.Name, town.TPCost)
}
Starting town (Midworld) typically has no teleport cost.
# Shop System
## Item Management
Towns have shops that sell specific items:
shopItems := town.GetShopItems()
fmt.Printf("%s sells %d different items\n", town.Name, len(shopItems))
if town.HasShopItem("5") {
fmt.Println("This town sells item ID 5")
}
## Shop Inventory
Shop inventories are stored as comma-separated item ID lists:
// Current inventory
items := town.GetShopItems() // Returns []string{"1", "2", "3"}
// Update inventory
newItems := []string{"10", "15", "20", "25"}
town.SetShopItems(newItems)
town.Save() // Persist changes
# Geographic Queries
## Coordinate System
Towns exist on a 2D coordinate plane:
fmt.Printf("%s is located at (%d, %d)\n", town.Name, town.X, town.Y)
// Calculate distance between towns
distance := town1.DistanceFrom(town2.X, town2.Y)
## Proximity Search
Find towns within a certain distance:
// Get all towns within 50 units of player position
nearbyTowns, err := towns.ByDistance(db, playerX, playerY, 50)
// Results are ordered by distance (closest first)
if len(nearbyTowns) > 0 {
closest := nearbyTowns[0]
fmt.Printf("Closest town: %s\n", closest.Name)
}
## Starting Location
The starting town is identifiable:
if town.IsStartingTown() {
fmt.Println("This is where new players begin")
}
Starting towns are typically at coordinates (0, 0).
# Cost-Based Queries
## Budget-Friendly Services
Find towns within budget constraints:
// Towns with affordable inns
cheapInns, err := towns.ByMaxInnCost(db, playerGold)
// Towns with affordable teleportation
affordableTP, err := towns.ByMaxTPCost(db, playerGold)
Results are ordered by cost (cheapest first).
## Service Availability
Check what services a town offers:
fmt.Printf("Services at %s:\n", town.Name)
if town.InnCost > 0 {
fmt.Printf("- Inn: %d gold\n", town.InnCost)
}
if town.MapCost > 0 {
fmt.Printf("- Map: %d gold\n", town.MapCost)
}
if town.TPCost > 0 {
fmt.Printf("- Teleport destination: %d gold\n", town.TPCost)
}
# Game Progression
## Town Hierarchy
Towns typically follow a progression pattern:
1. **Starting Towns** (Midworld): Free teleport, basic services, low costs
2. **Early Game Towns** (Roma, Bris): Moderate costs, expanded shops
3. **Mid Game Towns** (Kalle, Narcissa): Higher costs, specialized items
4. **Late Game Towns** (Hambry, Gilead, Endworld): Premium services, rare items
## Economic Scaling
Service costs often scale with game progression:
// Example progression analysis
towns, _ := towns.All(db)
for _, town := range towns {
if town.TPCost > 100 {
fmt.Printf("%s is a late-game destination\n", town.Name)
}
}
# World Map Integration
## Navigation
Towns serve as waypoints for world navigation:
// Find path between towns
startTown, _ := towns.ByName(db, "Midworld")
endTown, _ := towns.ByName(db, "Endworld")
distance := startTown.DistanceFrom(endTown.X, endTown.Y)
fmt.Printf("Distance from %s to %s: %.0f units\n",
startTown.Name, endTown.Name, distance)
## Strategic Planning
Use town data for strategic decisions:
// Find cheapest inn route
route := []string{"Midworld", "Roma", "Bris"}
totalInnCost := 0
for _, townName := range route {
town, _ := towns.ByName(db, townName)
totalInnCost += town.InnCost
}
fmt.Printf("Route inn cost: %d gold\n", totalInnCost)
# Error Handling
All functions return appropriate errors for common failure cases:
- Town not found (Find/ByName returns error for non-existent towns)
- Database connection issues
- Invalid operations (e.g., saving/deleting towns without IDs)
*/
package towns

View File

@ -2,39 +2,62 @@ package towns
import ( import (
"fmt" "fmt"
"math"
"strings" "strings"
"dk/internal/database" "dk/internal/database"
"dk/internal/helpers/scanner"
"zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite"
) )
// Town represents a town in the database // Town represents a town in the database
type Town struct { type Town struct {
ID int `json:"id"` ID int `db:"id" json:"id"`
Name string `json:"name"` Name string `db:"name" json:"name"`
X int `json:"x"` X int `db:"x" json:"x"`
Y int `json:"y"` Y int `db:"y" json:"y"`
InnCost int `json:"inn_cost"` InnCost int `db:"inn_cost" json:"inn_cost"`
MapCost int `json:"map_cost"` MapCost int `db:"map_cost" json:"map_cost"`
TPCost int `json:"tp_cost"` TPCost int `db:"tp_cost" json:"tp_cost"`
ShopList string `json:"shop_list"` ShopList string `db:"shop_list" json:"shop_list"`
}
// New creates a new Town with sensible defaults
func New() *Town {
return &Town{
Name: "",
X: 0, // Default coordinates
Y: 0,
InnCost: 50, // Default inn cost
MapCost: 100, // Default map cost
TPCost: 25, // Default teleport cost
ShopList: "", // No items by default
}
}
var townScanner = scanner.New[Town]()
// townColumns returns the column list for town queries
func townColumns() string {
return townScanner.Columns()
}
// scanTown populates a Town struct using the fast scanner
func scanTown(stmt *sqlite.Stmt) *Town {
town := &Town{}
townScanner.Scan(stmt, town)
return town
} }
// Find retrieves a town by ID // Find retrieves a town by ID
func Find(id int) (*Town, error) { func Find(id int) (*Town, error) {
town := &Town{} var town *Town
query := `SELECT ` + townColumns() + ` FROM towns WHERE id = ?`
query := "SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list FROM towns WHERE id = ?"
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
town.ID = stmt.ColumnInt(0) town = scanTown(stmt)
town.Name = stmt.ColumnText(1)
town.X = stmt.ColumnInt(2)
town.Y = stmt.ColumnInt(3)
town.InnCost = stmt.ColumnInt(4)
town.MapCost = stmt.ColumnInt(5)
town.TPCost = stmt.ColumnInt(6)
town.ShopList = stmt.ColumnText(7)
return nil return nil
}, id) }, id)
@ -42,7 +65,7 @@ func Find(id int) (*Town, error) {
return nil, fmt.Errorf("failed to find town: %w", err) return nil, fmt.Errorf("failed to find town: %w", err)
} }
if town.ID == 0 { if town == nil {
return nil, fmt.Errorf("town with ID %d not found", id) return nil, fmt.Errorf("town with ID %d not found", id)
} }
@ -53,18 +76,10 @@ func Find(id int) (*Town, error) {
func All() ([]*Town, error) { func All() ([]*Town, error) {
var towns []*Town var towns []*Town
query := "SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list FROM towns ORDER BY id" query := `SELECT ` + townColumns() + ` FROM towns ORDER BY id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
town := &Town{ town := scanTown(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
X: stmt.ColumnInt(2),
Y: stmt.ColumnInt(3),
InnCost: stmt.ColumnInt(4),
MapCost: stmt.ColumnInt(5),
TPCost: stmt.ColumnInt(6),
ShopList: stmt.ColumnText(7),
}
towns = append(towns, town) towns = append(towns, town)
return nil return nil
}) })
@ -78,18 +93,12 @@ func All() ([]*Town, error) {
// ByName retrieves a town by name (case-insensitive) // ByName retrieves a town by name (case-insensitive)
func ByName(name string) (*Town, error) { func ByName(name string) (*Town, error) {
town := &Town{} var town *Town
query := `SELECT ` + townColumns() + ` FROM towns WHERE LOWER(name) = LOWER(?) LIMIT 1`
query := "SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list FROM towns WHERE LOWER(name) = LOWER(?) LIMIT 1"
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
town.ID = stmt.ColumnInt(0) town = scanTown(stmt)
town.Name = stmt.ColumnText(1)
town.X = stmt.ColumnInt(2)
town.Y = stmt.ColumnInt(3)
town.InnCost = stmt.ColumnInt(4)
town.MapCost = stmt.ColumnInt(5)
town.TPCost = stmt.ColumnInt(6)
town.ShopList = stmt.ColumnText(7)
return nil return nil
}, name) }, name)
@ -97,7 +106,7 @@ func ByName(name string) (*Town, error) {
return nil, fmt.Errorf("failed to find town by name: %w", err) return nil, fmt.Errorf("failed to find town by name: %w", err)
} }
if town.ID == 0 { if town == nil {
return nil, fmt.Errorf("town with name '%s' not found", name) return nil, fmt.Errorf("town with name '%s' not found", name)
} }
@ -108,18 +117,10 @@ func ByName(name string) (*Town, error) {
func ByMaxInnCost(maxCost int) ([]*Town, error) { func ByMaxInnCost(maxCost int) ([]*Town, error) {
var towns []*Town var towns []*Town
query := "SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list FROM towns WHERE inn_cost <= ? ORDER BY inn_cost, id" query := `SELECT ` + townColumns() + ` FROM towns WHERE inn_cost <= ? ORDER BY inn_cost, id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
town := &Town{ town := scanTown(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
X: stmt.ColumnInt(2),
Y: stmt.ColumnInt(3),
InnCost: stmt.ColumnInt(4),
MapCost: stmt.ColumnInt(5),
TPCost: stmt.ColumnInt(6),
ShopList: stmt.ColumnText(7),
}
towns = append(towns, town) towns = append(towns, town)
return nil return nil
}, maxCost) }, maxCost)
@ -135,18 +136,10 @@ func ByMaxInnCost(maxCost int) ([]*Town, error) {
func ByMaxTPCost(maxCost int) ([]*Town, error) { func ByMaxTPCost(maxCost int) ([]*Town, error) {
var towns []*Town var towns []*Town
query := "SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list FROM towns WHERE tp_cost <= ? ORDER BY tp_cost, id" query := `SELECT ` + townColumns() + ` FROM towns WHERE tp_cost <= ? ORDER BY tp_cost, id`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
town := &Town{ town := scanTown(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
X: stmt.ColumnInt(2),
Y: stmt.ColumnInt(3),
InnCost: stmt.ColumnInt(4),
MapCost: stmt.ColumnInt(5),
TPCost: stmt.ColumnInt(6),
ShopList: stmt.ColumnText(7),
}
towns = append(towns, town) towns = append(towns, town)
return nil return nil
}, maxCost) }, maxCost)
@ -160,24 +153,17 @@ func ByMaxTPCost(maxCost int) ([]*Town, error) {
// ByCoords retrieves a town by its x, y coordinates // ByCoords retrieves a town by its x, y coordinates
func ByCoords(x, y int) (*Town, error) { func ByCoords(x, y int) (*Town, error) {
town := &Town{} var town *Town
query := `SELECT ` + townColumns() + ` FROM towns WHERE x = ? AND y = ? LIMIT 1`
query := `SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list
FROM towns WHERE x = ? AND y = ? LIMIT 1`
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
town.ID = stmt.ColumnInt(0) town = scanTown(stmt)
town.Name = stmt.ColumnText(1)
town.X = stmt.ColumnInt(2)
town.Y = stmt.ColumnInt(3)
town.InnCost = stmt.ColumnInt(4)
town.MapCost = stmt.ColumnInt(5)
town.TPCost = stmt.ColumnInt(6)
town.ShopList = stmt.ColumnText(7)
return nil return nil
}, x, y) }, x, y)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to retrieve towns by distance: %w", err) return nil, fmt.Errorf("failed to retrieve town by coordinates: %w", err)
} }
return town, nil return town, nil
@ -187,23 +173,14 @@ func ByCoords(x, y int) (*Town, error) {
func ByDistance(fromX, fromY, maxDistance int) ([]*Town, error) { func ByDistance(fromX, fromY, maxDistance int) ([]*Town, error) {
var towns []*Town var towns []*Town
query := `SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list query := `SELECT ` + townColumns() + `
FROM towns FROM towns
WHERE ((x - ?) * (x - ?) + (y - ?) * (y - ?)) <= ? WHERE ((x - ?) * (x - ?) + (y - ?) * (y - ?)) <= ?
ORDER BY ((x - ?) * (x - ?) + (y - ?) * (y - ?)), id` ORDER BY ((x - ?) * (x - ?) + (y - ?) * (y - ?)), id`
maxDistance2 := maxDistance * maxDistance maxDistance2 := maxDistance * maxDistance
err := database.Query(query, func(stmt *sqlite.Stmt) error { err := database.Query(query, func(stmt *sqlite.Stmt) error {
town := &Town{ town := scanTown(stmt)
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
X: stmt.ColumnInt(2),
Y: stmt.ColumnInt(3),
InnCost: stmt.ColumnInt(4),
MapCost: stmt.ColumnInt(5),
TPCost: stmt.ColumnInt(6),
ShopList: stmt.ColumnText(7),
}
towns = append(towns, town) towns = append(towns, town)
return nil return nil
}, fromX, fromX, fromY, fromY, maxDistance2, fromX, fromX, fromY, fromY) }, fromX, fromX, fromY, fromY, maxDistance2, fromX, fromX, fromY, fromY)
@ -225,6 +202,37 @@ func (t *Town) Save() error {
return database.Exec(query, t.Name, t.X, t.Y, t.InnCost, t.MapCost, t.TPCost, t.ShopList, t.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 {
if t.ID != 0 {
return fmt.Errorf("town already has ID %d, use Save() to update", t.ID)
}
// 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 // Delete removes the town from the database
func (t *Town) Delete() error { func (t *Town) Delete() error {
if t.ID == 0 { if t.ID == 0 {
@ -259,13 +267,18 @@ func (t *Town) HasShopItem(itemID string) bool {
return false return false
} }
// DistanceFrom calculates the distance from this town to given coordinates // DistanceFrom calculates the squared distance from this town to given coordinates
func (t *Town) DistanceFrom(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
func (t *Town) DistanceFrom(x, y int) float64 {
return math.Sqrt(t.DistanceFromSquared(x, y))
}
// IsStartingTown returns true if this is the starting town (Midworld) // IsStartingTown 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
@ -285,3 +298,39 @@ func (t *Town) CanAffordMap(gold int) bool {
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
func (t *Town) HasShop() bool {
return len(t.GetShopItems()) > 0
}
// GetPosition returns the town's coordinates
func (t *Town) GetPosition() (int, int) {
return t.X, t.Y
}
// SetPosition sets the town's coordinates
func (t *Town) SetPosition(x, y int) {
t.X = x
t.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

@ -1,500 +0,0 @@
/*
Package users is the active record implementation for user accounts in the game.
The users package provides comprehensive user management for the game, including authentication, character progression, inventory, equipment, and game state management. It handles all aspects of player accounts from registration to advanced gameplay features.
# Basic Usage
To retrieve a user by ID:
user, err := users.Find(db, 1)
if err != nil {
log.Fatal(err)
}
fmt.Printf("User: %s (Level %d)\n", user.Username, user.Level)
To find a user by username:
user, err := users.ByUsername(db, "playerName")
if err != nil {
log.Fatal(err)
}
To find a user by email:
user, err := users.ByEmail(db, "player@example.com")
if err != nil {
log.Fatal(err)
}
# Creating Users with Builder Pattern
The package provides a comprehensive builder for creating new user accounts:
## Basic User Creation
user, err := users.NewBuilder(db).
WithUsername("newplayer").
WithPassword("hashedPassword").
WithEmail("newplayer@example.com").
WithClassID(1).
Create()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Created user with ID: %d\n", user.ID)
## Advanced User Creation
user, err := users.NewBuilder(db).
WithUsername("hero").
WithPassword("secureHash").
WithEmail("hero@example.com").
WithVerified(true).
WithClassID(2).
WithPosition(100, -50).
WithLevel(5).
WithGold(500).
WithStats(8, 7, 10, 9). // strength, dex, attack, defense
WithHP(30, 30).
WithMP(20, 20).
WithSpells([]string{"1", "3", "5"}).
WithTowns([]string{"1", "2"}).
AsAdmin().
Create()
The builder automatically sets sensible defaults for all fields if not specified.
# User Management
## Authentication and Verification
user, _ := users.Find(db, userID)
// Check verification status
if user.IsVerified() {
fmt.Println("User email is verified")
}
// Check authorization levels
if user.IsAdmin() {
fmt.Println("User has admin privileges")
}
if user.IsModerator() {
fmt.Println("User has moderator privileges")
}
## Activity Tracking
// Update last online time
user.UpdateLastOnline()
user.Save()
// Get activity information
registered := user.RegisteredTime()
lastOnline := user.LastOnlineTime()
fmt.Printf("Registered: %s\n", registered.Format("Jan 2, 2006"))
fmt.Printf("Last online: %s\n", lastOnline.Format("Jan 2 15:04"))
# Character Management
## Stats and Progression
user, _ := users.Find(db, userID)
// Get character stats
stats := user.GetStats()
fmt.Printf("Level %d: HP %d/%d, MP %d/%d\n",
stats["level"], stats["hp"], stats["max_hp"],
stats["mp"], stats["max_mp"])
// Update character progression
user.Level = 10
user.Exp = 5000
user.MaxHP = 50
user.HP = 50
user.Save()
## Position and Movement
// Get current position
x, y := user.GetPosition()
fmt.Printf("Player at (%d, %d)\n", x, y)
// Move player
user.SetPosition(newX, newY)
user.Currently = "Exploring the forest"
user.Save()
## Combat Status
if user.IsFighting() {
fmt.Printf("Fighting monster ID %d (HP: %d)\n",
user.MonsterID, user.MonsterHP)
}
if user.IsAlive() {
fmt.Printf("Player has %d HP remaining\n", user.HP)
}
# Spell System
## Spell Management
user, _ := users.Find(db, userID)
// Get known spells
spells := user.GetSpellIDs()
fmt.Printf("Player knows %d spells: %v\n", len(spells), spells)
// Check if player knows a specific spell
if user.HasSpell("5") {
fmt.Println("Player knows spell 5")
}
// Learn new spells
newSpells := append(spells, "7", "8")
user.SetSpellIDs(newSpells)
user.Save()
## Spell Integration
func castSpell(db *database.DB, userID int, spellID string) error {
user, err := users.Find(db, userID)
if err != nil {
return err
}
if !user.HasSpell(spellID) {
return fmt.Errorf("user doesn't know spell %s", spellID)
}
// Spell casting logic here...
return nil
}
# Town and Travel System
## Town Visits
user, _ := users.Find(db, userID)
// Get visited towns
towns := user.GetTownIDs()
fmt.Printf("Visited %d towns: %v\n", len(towns), towns)
// Check if player has visited a town
if user.HasVisitedTown("3") {
fmt.Println("Player has been to town 3")
}
// Visit new town
visitedTowns := append(towns, "4")
user.SetTownIDs(visitedTowns)
user.Save()
## Travel Integration
func visitTown(db *database.DB, userID int, townID string) error {
user, err := users.Find(db, userID)
if err != nil {
return err
}
// Add town to visited list if not already there
if !user.HasVisitedTown(townID) {
towns := user.GetTownIDs()
user.SetTownIDs(append(towns, townID))
}
// Update position and status
// town coordinates would be looked up here
user.Currently = fmt.Sprintf("In town %s", townID)
return user.Save()
}
# Equipment System
## Equipment Management
user, _ := users.Find(db, userID)
// Get all equipment
equipment := user.GetEquipment()
weapon := equipment["weapon"].(map[string]any)
armor := equipment["armor"].(map[string]any)
fmt.Printf("Weapon: %s (ID: %d)\n", weapon["name"], weapon["id"])
fmt.Printf("Armor: %s (ID: %d)\n", armor["name"], armor["id"])
// Equip new items
user.WeaponID = 15
user.WeaponName = "Dragon Sword"
user.ArmorID = 8
user.ArmorName = "Steel Plate"
user.Save()
# Query Operations
## Level-Based Queries
// Get all players at a specific level
level5Players, err := users.ByLevel(db, 5)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Level 5 players (%d):\n", len(level5Players))
for _, player := range level5Players {
fmt.Printf("- %s (EXP: %d)\n", player.Username, player.Exp)
}
## Online Status Queries
// Get players online in the last hour
onlinePlayers, err := users.Online(db, time.Hour)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Players online in last hour (%d):\n", len(onlinePlayers))
for _, player := range onlinePlayers {
lastSeen := time.Since(player.LastOnlineTime())
fmt.Printf("- %s (last seen %v ago)\n", player.Username, lastSeen)
}
# Database Schema
The users table contains extensive character and game state information:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
password TEXT NOT NULL,
email TEXT NOT NULL,
verified INTEGER NOT NULL DEFAULT 0,
token TEXT NOT NULL DEFAULT '',
registered INTEGER NOT NULL DEFAULT (unixepoch()),
last_online INTEGER NOT NULL DEFAULT (unixepoch()),
auth INTEGER NOT NULL DEFAULT 0,
x INTEGER NOT NULL DEFAULT 0,
y INTEGER NOT NULL DEFAULT 0,
class_id INTEGER NOT NULL DEFAULT 0,
currently TEXT NOT NULL DEFAULT 'In Town',
fighting INTEGER NOT NULL DEFAULT 0,
-- Combat state fields
monster_id INTEGER NOT NULL DEFAULT 0,
monster_hp INTEGER NOT NULL DEFAULT 0,
monster_sleep INTEGER NOT NULL DEFAULT 0,
monster_immune INTEGER NOT NULL DEFAULT 0,
uber_damage INTEGER NOT NULL DEFAULT 0,
uber_defense INTEGER NOT NULL DEFAULT 0,
-- Character stats
hp INTEGER NOT NULL DEFAULT 15,
mp INTEGER NOT NULL DEFAULT 0,
tp INTEGER NOT NULL DEFAULT 10,
max_hp INTEGER NOT NULL DEFAULT 15,
max_mp INTEGER NOT NULL DEFAULT 0,
max_tp INTEGER NOT NULL DEFAULT 10,
level INTEGER NOT NULL DEFAULT 1,
gold INTEGER NOT NULL DEFAULT 100,
exp INTEGER NOT NULL DEFAULT 0,
gold_bonus INTEGER NOT NULL DEFAULT 0,
exp_bonus INTEGER NOT NULL DEFAULT 0,
strength INTEGER NOT NULL DEFAULT 5,
dexterity INTEGER NOT NULL DEFAULT 5,
attack INTEGER NOT NULL DEFAULT 5,
defense INTEGER NOT NULL DEFAULT 5,
-- Equipment
weapon_id INTEGER NOT NULL DEFAULT 0,
armor_id INTEGER NOT NULL DEFAULT 0,
shield_id INTEGER NOT NULL DEFAULT 0,
slot_1_id INTEGER NOT NULL DEFAULT 0,
slot_2_id INTEGER NOT NULL DEFAULT 0,
slot_3_id INTEGER NOT NULL DEFAULT 0,
weapon_name TEXT NOT NULL DEFAULT '',
armor_name TEXT NOT NULL DEFAULT '',
shield_name TEXT NOT NULL DEFAULT '',
slot_1_name TEXT NOT NULL DEFAULT '',
slot_2_name TEXT NOT NULL DEFAULT '',
slot_3_name TEXT NOT NULL DEFAULT '',
-- Game state
drop_code INTEGER NOT NULL DEFAULT 0,
spells TEXT NOT NULL DEFAULT '',
towns TEXT NOT NULL DEFAULT ''
)
# Advanced Features
## Character Progression
func levelUpCharacter(user *users.User, newLevel int) {
user.Level = newLevel
// Increase base stats
user.MaxHP += 5
user.HP = user.MaxHP // Full heal on level up
user.MaxMP += 2
user.MP = user.MaxMP
// Stat bonuses
user.Strength++
user.Attack++
user.Defense++
user.Save()
}
## Combat Integration
func startCombat(user *users.User, monsterID int) error {
if user.IsFighting() {
return fmt.Errorf("already in combat")
}
user.Fighting = 1
user.MonsterID = monsterID
// monster HP would be looked up from monsters table
user.MonsterHP = 50
user.Currently = "Fighting"
return user.Save()
}
func endCombat(user *users.User, won bool) error {
user.Fighting = 0
user.MonsterID = 0
user.MonsterHP = 0
user.MonsterSleep = 0
user.MonsterImmune = 0
if won {
user.Currently = "Victorious"
// Award experience and gold
} else {
user.Currently = "Defeated"
user.HP = 0 // Player defeated
}
return user.Save()
}
## Administrative Functions
func promoteUser(db *database.DB, username string, authLevel int) error {
user, err := users.ByUsername(db, username)
if err != nil {
return err
}
user.Auth = authLevel
return user.Save()
}
func getUsersByAuthLevel(db *database.DB, minAuth int) ([]*users.User, error) {
allUsers, err := users.All(db)
if err != nil {
return nil, err
}
var authorizedUsers []*users.User
for _, user := range allUsers {
if user.Auth >= minAuth {
authorizedUsers = append(authorizedUsers, user)
}
}
return authorizedUsers, nil
}
# Performance Considerations
The users table is large and frequently accessed. Consider:
## Efficient Queries
// Use specific lookups when possible
user, _ := users.ByUsername(db, username) // Uses index
user, _ := users.ByEmail(db, email) // Uses index
// Limit results for admin interfaces
onlineUsers, _ := users.Online(db, time.Hour) // Bounded by time
levelUsers, _ := users.ByLevel(db, targetLevel) // Bounded by level
## Caching Strategies
// Cache frequently accessed user data
type UserCache struct {
users map[int]*users.User
mutex sync.RWMutex
}
func (c *UserCache) GetUser(db *database.DB, id int) (*users.User, error) {
c.mutex.RLock()
if user, ok := c.users[id]; ok {
c.mutex.RUnlock()
return user, nil
}
c.mutex.RUnlock()
user, err := users.Find(db, id)
if err != nil {
return nil, err
}
c.mutex.Lock()
c.users[id] = user
c.mutex.Unlock()
return user, nil
}
# Integration Examples
## Session Management
func authenticateUser(db *database.DB, username, password string) (*users.User, error) {
user, err := users.ByUsername(db, username)
if err != nil {
return nil, fmt.Errorf("user not found")
}
if !user.IsVerified() {
return nil, fmt.Errorf("email not verified")
}
// Verify password (implement password checking)
if !verifyPassword(user.Password, password) {
return nil, fmt.Errorf("invalid password")
}
// Update last online
user.UpdateLastOnline()
user.Save()
return user, nil
}
## Game State Management
func saveGameState(user *users.User, gameData GameState) error {
user.X = gameData.X
user.Y = gameData.Y
user.HP = gameData.HP
user.MP = gameData.MP
user.Currently = gameData.Status
if gameData.InCombat {
user.Fighting = 1
user.MonsterID = gameData.MonsterID
user.MonsterHP = gameData.MonsterHP
}
return user.Save()
}
The users package provides comprehensive player account management with support for all game mechanics including character progression, combat, equipment, spells, and world exploration.
*/
package users

View File

@ -6,7 +6,7 @@ import (
"time" "time"
"dk/internal/database" "dk/internal/database"
"dk/internal/utils/scanner" "dk/internal/helpers/scanner"
"zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite"
) )