Compare commits

..

10 Commits

Author SHA1 Message Date
c7d08d8004 create users package 2025-08-09 00:05:06 -05:00
96857e8110 create forum package 2025-08-08 23:52:23 -05:00
42e090b05f create control package 2025-08-08 23:46:12 -05:00
906042a67e create babble package 2025-08-08 23:42:24 -05:00
089246dd25 create news package 2025-08-08 23:37:08 -05:00
a6b34b7b87 create towns package 2025-08-08 23:33:16 -05:00
89af7644ba split builder components out 2025-08-08 23:27:35 -05:00
53d131a96e create spells package 2025-08-08 23:26:16 -05:00
a4d4dd9777 create monsters package 2025-08-08 23:22:01 -05:00
ace43e1053 create drops package 2025-08-08 23:14:47 -05:00
38 changed files with 11394 additions and 135 deletions

350
internal/babble/babble.go Normal file
View File

@ -0,0 +1,350 @@
package babble
import (
"fmt"
"strings"
"time"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Babble represents a global chat message in the database
type Babble struct {
ID int `json:"id"`
Posted int64 `json:"posted"`
Author string `json:"author"`
Babble string `json:"babble"`
db *database.DB
}
// Find retrieves a babble message by ID
func Find(db *database.DB, id int) (*Babble, error) {
babble := &Babble{db: db}
query := "SELECT id, posted, author, babble FROM babble WHERE id = ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
babble.ID = stmt.ColumnInt(0)
babble.Posted = stmt.ColumnInt64(1)
babble.Author = stmt.ColumnText(2)
babble.Babble = stmt.ColumnText(3)
return nil
}, id)
if err != nil {
return nil, fmt.Errorf("failed to find babble: %w", err)
}
if babble.ID == 0 {
return nil, fmt.Errorf("babble with ID %d not found", id)
}
return babble, nil
}
// All retrieves all babble messages ordered by posted time (newest first)
func All(db *database.DB) ([]*Babble, error) {
var babbles []*Babble
query := "SELECT id, posted, author, babble FROM babble ORDER BY posted DESC, id DESC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
babble := &Babble{
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
Author: stmt.ColumnText(2),
Babble: stmt.ColumnText(3),
db: db,
}
babbles = append(babbles, babble)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to retrieve all babble: %w", err)
}
return babbles, nil
}
// ByAuthor retrieves babble messages by a specific author
func ByAuthor(db *database.DB, author string) ([]*Babble, error) {
var babbles []*Babble
query := "SELECT id, posted, author, babble FROM babble WHERE LOWER(author) = LOWER(?) ORDER BY posted DESC, id DESC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
babble := &Babble{
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
Author: stmt.ColumnText(2),
Babble: stmt.ColumnText(3),
db: db,
}
babbles = append(babbles, babble)
return nil
}, author)
if err != nil {
return nil, fmt.Errorf("failed to retrieve babble by author: %w", err)
}
return babbles, nil
}
// Recent retrieves the most recent babble messages (limited by count)
func Recent(db *database.DB, limit int) ([]*Babble, error) {
var babbles []*Babble
query := "SELECT id, posted, author, babble FROM babble ORDER BY posted DESC, id DESC LIMIT ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
babble := &Babble{
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
Author: stmt.ColumnText(2),
Babble: stmt.ColumnText(3),
db: db,
}
babbles = append(babbles, babble)
return nil
}, limit)
if err != nil {
return nil, fmt.Errorf("failed to retrieve recent babble: %w", err)
}
return babbles, nil
}
// Since retrieves babble messages since a specific timestamp
func Since(db *database.DB, since int64) ([]*Babble, error) {
var babbles []*Babble
query := "SELECT id, posted, author, babble FROM babble WHERE posted >= ? ORDER BY posted DESC, id DESC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
babble := &Babble{
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
Author: stmt.ColumnText(2),
Babble: stmt.ColumnText(3),
db: db,
}
babbles = append(babbles, babble)
return nil
}, since)
if err != nil {
return nil, fmt.Errorf("failed to retrieve babble since timestamp: %w", err)
}
return babbles, nil
}
// Between retrieves babble messages between two timestamps (inclusive)
func Between(db *database.DB, start, end int64) ([]*Babble, error) {
var babbles []*Babble
query := "SELECT id, posted, author, babble FROM babble WHERE posted >= ? AND posted <= ? ORDER BY posted DESC, id DESC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
babble := &Babble{
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
Author: stmt.ColumnText(2),
Babble: stmt.ColumnText(3),
db: db,
}
babbles = append(babbles, babble)
return nil
}, start, end)
if err != nil {
return nil, fmt.Errorf("failed to retrieve babble between timestamps: %w", err)
}
return babbles, nil
}
// Search retrieves babble messages containing the search term (case-insensitive)
func Search(db *database.DB, term string) ([]*Babble, error) {
var babbles []*Babble
query := "SELECT id, posted, author, babble FROM babble WHERE LOWER(babble) LIKE LOWER(?) ORDER BY posted DESC, id DESC"
searchTerm := "%" + term + "%"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
babble := &Babble{
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
Author: stmt.ColumnText(2),
Babble: stmt.ColumnText(3),
db: db,
}
babbles = append(babbles, babble)
return nil
}, searchTerm)
if err != nil {
return nil, fmt.Errorf("failed to search babble: %w", err)
}
return babbles, nil
}
// RecentByAuthor retrieves recent messages from a specific author
func RecentByAuthor(db *database.DB, author string, limit int) ([]*Babble, error) {
var babbles []*Babble
query := "SELECT id, posted, author, babble FROM babble WHERE LOWER(author) = LOWER(?) ORDER BY posted DESC, id DESC LIMIT ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
babble := &Babble{
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
Author: stmt.ColumnText(2),
Babble: stmt.ColumnText(3),
db: db,
}
babbles = append(babbles, babble)
return nil
}, author, limit)
if err != nil {
return nil, fmt.Errorf("failed to retrieve recent babble by author: %w", err)
}
return babbles, nil
}
// Save updates an existing babble message in the database
func (b *Babble) Save() error {
if b.ID == 0 {
return fmt.Errorf("cannot save babble without ID")
}
query := `UPDATE babble SET posted = ?, author = ?, babble = ? WHERE id = ?`
return b.db.Exec(query, b.Posted, b.Author, b.Babble, b.ID)
}
// Delete removes the babble message from the database
func (b *Babble) Delete() error {
if b.ID == 0 {
return fmt.Errorf("cannot delete babble without ID")
}
query := "DELETE FROM babble WHERE id = ?"
return b.db.Exec(query, b.ID)
}
// PostedTime returns the posted timestamp as a time.Time
func (b *Babble) PostedTime() time.Time {
return time.Unix(b.Posted, 0)
}
// SetPostedTime sets the posted timestamp from a time.Time
func (b *Babble) SetPostedTime(t time.Time) {
b.Posted = t.Unix()
}
// IsRecent returns true if the babble message was posted within the last hour
func (b *Babble) IsRecent() bool {
return time.Since(b.PostedTime()) < time.Hour
}
// Age returns how long ago the babble message was posted
func (b *Babble) Age() time.Duration {
return time.Since(b.PostedTime())
}
// IsAuthor returns true if the given username is the author of this babble message
func (b *Babble) IsAuthor(username string) bool {
return strings.EqualFold(b.Author, username)
}
// Preview returns a truncated version of the babble for previews
func (b *Babble) Preview(maxLength int) string {
if len(b.Babble) <= maxLength {
return b.Babble
}
if maxLength < 3 {
return b.Babble[:maxLength]
}
return b.Babble[:maxLength-3] + "..."
}
// WordCount returns the number of words in the babble message
func (b *Babble) WordCount() int {
if b.Babble == "" {
return 0
}
// Simple word count by splitting on whitespace
words := 0
inWord := false
for _, char := range b.Babble {
if char == ' ' || char == '\t' || char == '\n' || char == '\r' {
if inWord {
words++
inWord = false
}
} else {
inWord = true
}
}
if inWord {
words++
}
return words
}
// Length returns the character length of the babble message
func (b *Babble) Length() int {
return len(b.Babble)
}
// Contains returns true if the babble message contains the given term (case-insensitive)
func (b *Babble) Contains(term string) bool {
return strings.Contains(strings.ToLower(b.Babble), strings.ToLower(term))
}
// IsEmpty returns true if the babble message is empty or whitespace-only
func (b *Babble) IsEmpty() bool {
return strings.TrimSpace(b.Babble) == ""
}
// IsLongMessage returns true if the message exceeds the typical chat length
func (b *Babble) IsLongMessage(threshold int) bool {
return b.Length() > threshold
}
// GetMentions returns a slice of usernames mentioned in the message (starting with @)
func (b *Babble) GetMentions() []string {
words := strings.Fields(b.Babble)
var mentions []string
for _, word := range words {
if strings.HasPrefix(word, "@") && len(word) > 1 {
// Clean up punctuation from the end
mention := strings.TrimRight(word[1:], ".,!?;:")
if mention != "" {
mentions = append(mentions, mention)
}
}
}
return mentions
}
// HasMention returns true if the message mentions the given username
func (b *Babble) HasMention(username string) bool {
mentions := b.GetMentions()
for _, mention := range mentions {
if strings.EqualFold(mention, username) {
return true
}
}
return false
}

View File

@ -0,0 +1,625 @@
package babble
import (
"os"
"testing"
"time"
"dk/internal/database"
)
func setupTestDB(t *testing.T) *database.DB {
testDB := "test_babble.db"
t.Cleanup(func() {
os.Remove(testDB)
})
db, err := database.Open(testDB)
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
// Create babble table
createTable := `CREATE TABLE babble (
id INTEGER PRIMARY KEY AUTOINCREMENT,
posted INTEGER NOT NULL DEFAULT (unixepoch()),
author TEXT NOT NULL DEFAULT '',
babble TEXT NOT NULL DEFAULT ''
)`
if err := db.Exec(createTable); err != nil {
t.Fatalf("Failed to create babble table: %v", err)
}
// Insert test data with specific timestamps for predictable testing
now := time.Now().Unix()
testBabble := `INSERT INTO babble (posted, author, babble) VALUES
(?, 'Alice', 'Hello everyone! Welcome to the game'),
(?, 'Bob', 'Thanks Alice! @Alice this game is great'),
(?, 'Charlie', 'Anyone want to team up for the dungeon?'),
(?, 'Alice', 'I can help @Charlie, let me know'),
(?, 'David', 'Server lag is really bad right now...'),
(?, 'Eve', 'Quick question about spell mechanics')`
timestamps := []interface{}{
now - 3600*6, // 6 hours ago
now - 3600*4, // 4 hours ago
now - 3600*2, // 2 hours ago
now - 3600*1, // 1 hour ago
now - 1800, // 30 minutes ago
now - 300, // 5 minutes ago
}
if err := db.Exec(testBabble, timestamps...); err != nil {
t.Fatalf("Failed to insert test babble: %v", err)
}
return db
}
func TestFind(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test finding existing babble
babble, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find babble: %v", err)
}
if babble.ID != 1 {
t.Errorf("Expected ID 1, got %d", babble.ID)
}
if babble.Author != "Alice" {
t.Errorf("Expected author 'Alice', got '%s'", babble.Author)
}
if babble.Babble != "Hello everyone! Welcome to the game" {
t.Errorf("Expected specific message, got '%s'", babble.Babble)
}
if babble.Posted == 0 {
t.Error("Expected non-zero posted timestamp")
}
// Test finding non-existent babble
_, err = Find(db, 999)
if err == nil {
t.Error("Expected error when finding non-existent babble")
}
}
func TestAll(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
babbles, err := All(db)
if err != nil {
t.Fatalf("Failed to get all babble: %v", err)
}
if len(babbles) != 6 {
t.Errorf("Expected 6 babble messages, got %d", len(babbles))
}
// Check ordering (newest first)
if len(babbles) >= 2 {
if babbles[0].Posted < babbles[1].Posted {
t.Error("Expected babble to be ordered by posted time (newest first)")
}
}
// First message should be the most recent (5 minutes ago)
if babbles[0].Author != "Eve" {
t.Errorf("Expected newest message from Eve, got from '%s'", babbles[0].Author)
}
}
func TestByAuthor(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test messages by Alice
aliceMessages, err := ByAuthor(db, "Alice")
if err != nil {
t.Fatalf("Failed to get babble by author: %v", err)
}
if len(aliceMessages) != 2 {
t.Errorf("Expected 2 messages by Alice, got %d", len(aliceMessages))
}
// Verify all messages are by Alice
for _, message := range aliceMessages {
if message.Author != "Alice" {
t.Errorf("Expected author 'Alice', got '%s'", message.Author)
}
}
// Check ordering (newest first)
if len(aliceMessages) == 2 {
if aliceMessages[0].Babble != "I can help @Charlie, let me know" {
t.Errorf("Expected newest message by Alice first")
}
}
// Test case insensitive search
aliceMessagesLower, err := ByAuthor(db, "alice")
if err != nil {
t.Fatalf("Failed to get babble by lowercase author: %v", err)
}
if len(aliceMessagesLower) != 2 {
t.Errorf("Expected case insensitive search to find 2 messages, got %d", len(aliceMessagesLower))
}
// Test author with no messages
noMessages, err := ByAuthor(db, "NonexistentUser")
if err != nil {
t.Fatalf("Failed to query non-existent author: %v", err)
}
if len(noMessages) != 0 {
t.Errorf("Expected 0 messages by non-existent author, got %d", len(noMessages))
}
}
func TestRecent(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test getting 3 most recent messages
recentMessages, err := Recent(db, 3)
if err != nil {
t.Fatalf("Failed to get recent babble: %v", err)
}
if len(recentMessages) != 3 {
t.Errorf("Expected 3 recent messages, got %d", len(recentMessages))
}
// Check ordering (newest first)
if len(recentMessages) >= 2 {
if recentMessages[0].Posted < recentMessages[1].Posted {
t.Error("Expected recent messages to be ordered newest first")
}
}
// Test getting more messages than exist
allRecentMessages, err := Recent(db, 10)
if err != nil {
t.Fatalf("Failed to get recent babble with high limit: %v", err)
}
if len(allRecentMessages) != 6 {
t.Errorf("Expected 6 messages (all available), got %d", len(allRecentMessages))
}
}
func TestSince(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test messages since 3 hours ago
threeHoursAgo := time.Now().Add(-3 * time.Hour).Unix()
recentMessages, err := Since(db, threeHoursAgo)
if err != nil {
t.Fatalf("Failed to get babble since timestamp: %v", err)
}
// Should get messages from 2 hours ago, 1 hour ago, 30 minutes ago, and 5 minutes ago
expectedCount := 4
if len(recentMessages) != expectedCount {
t.Errorf("Expected %d messages since 3 hours ago, got %d", expectedCount, len(recentMessages))
}
// Verify all messages are since the timestamp
for _, message := range recentMessages {
if message.Posted < threeHoursAgo {
t.Errorf("Message with timestamp %d is before the 'since' timestamp %d", message.Posted, threeHoursAgo)
}
}
// Test with future timestamp (should return no messages)
futureMessages, err := Since(db, time.Now().Add(time.Hour).Unix())
if err != nil {
t.Fatalf("Failed to query future timestamp: %v", err)
}
if len(futureMessages) != 0 {
t.Errorf("Expected 0 messages since future timestamp, got %d", len(futureMessages))
}
}
func TestBetween(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test messages between 5 hours ago and 1 hour ago
start := time.Now().Add(-5 * time.Hour).Unix()
end := time.Now().Add(-1 * time.Hour).Unix()
betweenMessages, err := Between(db, start, end)
if err != nil {
t.Fatalf("Failed to get babble between timestamps: %v", err)
}
// Should get messages from 4 hours ago, 2 hours ago, and 1 hour ago (inclusive end)
expectedCount := 3
if len(betweenMessages) != expectedCount {
t.Errorf("Expected %d messages between timestamps, got %d", expectedCount, len(betweenMessages))
}
// Verify all messages are within the range
for _, message := range betweenMessages {
if message.Posted < start || message.Posted > end {
t.Errorf("Message with timestamp %d is outside range [%d, %d]", message.Posted, start, end)
}
}
}
func TestSearch(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test searching for "game"
gameMessages, err := Search(db, "game")
if err != nil {
t.Fatalf("Failed to search babble: %v", err)
}
expectedCount := 2 // Alice's welcome message and Bob's response
if len(gameMessages) != expectedCount {
t.Errorf("Expected %d messages containing 'game', got %d", expectedCount, len(gameMessages))
}
// Verify all messages contain the search term
for _, message := range gameMessages {
if !message.Contains("game") {
t.Errorf("Message '%s' does not contain search term 'game'", message.Babble)
}
}
// Test case insensitive search
gameMessagesUpper, err := Search(db, "GAME")
if err != nil {
t.Fatalf("Failed to search babble with uppercase: %v", err)
}
if len(gameMessagesUpper) != expectedCount {
t.Error("Expected case insensitive search to find same results")
}
// Test search with no results
noResults, err := Search(db, "nonexistentterm")
if err != nil {
t.Fatalf("Failed to search for non-existent term: %v", err)
}
if len(noResults) != 0 {
t.Errorf("Expected 0 results for non-existent term, got %d", len(noResults))
}
}
func TestRecentByAuthor(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test recent messages by Alice (limit 1)
aliceRecent, err := RecentByAuthor(db, "Alice", 1)
if err != nil {
t.Fatalf("Failed to get recent babble by author: %v", err)
}
if len(aliceRecent) != 1 {
t.Errorf("Expected 1 recent message by Alice, got %d", len(aliceRecent))
}
if len(aliceRecent) > 0 && aliceRecent[0].Babble != "I can help @Charlie, let me know" {
t.Error("Expected most recent message by Alice")
}
// Test with higher limit
aliceAll, err := RecentByAuthor(db, "Alice", 5)
if err != nil {
t.Fatalf("Failed to get all recent messages by Alice: %v", err)
}
if len(aliceAll) != 2 {
t.Errorf("Expected 2 total messages by Alice, got %d", len(aliceAll))
}
}
func TestBuilder(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Create new babble using builder
testTime := time.Now()
babble, err := NewBuilder(db).
WithAuthor("TestUser").
WithBabble("Test message from builder").
WithPostedTime(testTime).
Create()
if err != nil {
t.Fatalf("Failed to create babble with builder: %v", err)
}
if babble.ID == 0 {
t.Error("Expected non-zero ID after creation")
}
if babble.Author != "TestUser" {
t.Errorf("Expected author 'TestUser', got '%s'", babble.Author)
}
if babble.Babble != "Test message from builder" {
t.Errorf("Expected specific message, got '%s'", babble.Babble)
}
if babble.Posted != testTime.Unix() {
t.Errorf("Expected posted time %d, got %d", testTime.Unix(), babble.Posted)
}
// Test WithMessage alias
babble2, err := NewBuilder(db).
WithAuthor("TestUser2").
WithMessage("Using WithMessage alias").
Create()
if err != nil {
t.Fatalf("Failed to create babble with WithMessage: %v", err)
}
if babble2.Babble != "Using WithMessage alias" {
t.Errorf("WithMessage alias failed, got '%s'", babble2.Babble)
}
// Verify it was saved to database
foundBabble, err := Find(db, babble.ID)
if err != nil {
t.Fatalf("Failed to find created babble: %v", err)
}
if foundBabble.Babble != "Test message from builder" {
t.Errorf("Created babble not found in database")
}
// Test builder with default timestamp
defaultBabble, err := NewBuilder(db).
WithAuthor("DefaultUser").
WithBabble("Message with default timestamp").
Create()
if err != nil {
t.Fatalf("Failed to create babble with default timestamp: %v", err)
}
// Should have recent timestamp (within last minute)
if time.Since(defaultBabble.PostedTime()) > time.Minute {
t.Error("Expected default timestamp to be recent")
}
}
func TestSave(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
babble, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find babble: %v", err)
}
// Modify babble
babble.Author = "UpdatedAuthor"
babble.Babble = "Updated message content"
babble.Posted = time.Now().Unix()
// Save changes
err = babble.Save()
if err != nil {
t.Fatalf("Failed to save babble: %v", err)
}
// Verify changes were saved
updatedBabble, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find updated babble: %v", err)
}
if updatedBabble.Author != "UpdatedAuthor" {
t.Errorf("Expected updated author 'UpdatedAuthor', got '%s'", updatedBabble.Author)
}
if updatedBabble.Babble != "Updated message content" {
t.Errorf("Expected updated message, got '%s'", updatedBabble.Babble)
}
}
func TestDelete(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
babble, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find babble: %v", err)
}
// Delete babble
err = babble.Delete()
if err != nil {
t.Fatalf("Failed to delete babble: %v", err)
}
// Verify babble was deleted
_, err = Find(db, 1)
if err == nil {
t.Error("Expected error when finding deleted babble")
}
}
func TestUtilityMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
babble, _ := Find(db, 1)
// Test PostedTime
postedTime := babble.PostedTime()
if postedTime.IsZero() {
t.Error("Expected non-zero posted time")
}
// Test SetPostedTime
newTime := time.Now().Add(-30 * time.Minute)
babble.SetPostedTime(newTime)
if babble.Posted != newTime.Unix() {
t.Errorf("Expected posted timestamp %d, got %d", newTime.Unix(), babble.Posted)
}
// Test IsRecent (should be true for 30 minutes ago)
if !babble.IsRecent() {
t.Error("Expected message from 30 minutes ago to be recent")
}
// Test Age
age := babble.Age()
if age < 0 {
t.Error("Expected positive age")
}
// Test IsAuthor
if !babble.IsAuthor("Alice") {
t.Error("Expected IsAuthor to return true for correct author")
}
if !babble.IsAuthor("alice") { // Test case insensitive
t.Error("Expected IsAuthor to be case insensitive")
}
if babble.IsAuthor("Bob") {
t.Error("Expected IsAuthor to return false for incorrect author")
}
// Test Preview
longMessage := "This is a very long chat message that should be truncated when preview is called for display purposes"
babble.Babble = longMessage
preview := babble.Preview(20)
if len(preview) > 20 {
t.Errorf("Expected preview length <= 20, got %d", len(preview))
}
if preview[len(preview)-3:] != "..." {
t.Error("Expected preview to end with ellipsis")
}
shortPreview := babble.Preview(200) // Longer than message
if shortPreview != longMessage {
t.Error("Expected short message to not be truncated")
}
// Test WordCount
babble.Babble = "This is a test with five words"
wordCount := babble.WordCount()
if wordCount != 7 {
t.Errorf("Expected 7 words, got %d", wordCount)
}
// Test Length
expectedLength := len(babble.Babble)
if babble.Length() != expectedLength {
t.Errorf("Expected length %d, got %d", expectedLength, babble.Length())
}
// Test Contains
if !babble.Contains("test") {
t.Error("Expected message to contain 'test'")
}
if !babble.Contains("TEST") { // Case insensitive
t.Error("Expected Contains to be case insensitive")
}
if babble.Contains("nonexistent") {
t.Error("Expected message not to contain 'nonexistent'")
}
// Test IsEmpty
babble.Babble = ""
if !babble.IsEmpty() {
t.Error("Expected empty message to be empty")
}
babble.Babble = " "
if !babble.IsEmpty() {
t.Error("Expected whitespace-only message to be empty")
}
babble.Babble = "Not empty"
if babble.IsEmpty() {
t.Error("Expected non-empty message not to be empty")
}
// Test IsLongMessage
shortMsg := "Short"
babble.Babble = shortMsg
if babble.IsLongMessage(100) {
t.Error("Expected short message not to be long")
}
if !babble.IsLongMessage(3) {
t.Error("Expected message longer than threshold to be long")
}
}
func TestMentionMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test GetMentions
babble, _ := Find(db, 2) // Bob's message: "Thanks Alice! @Alice this game is great"
mentions := babble.GetMentions()
expectedMentions := []string{"Alice"}
if len(mentions) != len(expectedMentions) {
t.Errorf("Expected %d mentions, got %d", len(expectedMentions), len(mentions))
}
for i, expected := range expectedMentions {
if i < len(mentions) && mentions[i] != expected {
t.Errorf("Expected mention '%s' at position %d, got '%s'", expected, i, mentions[i])
}
}
// Test HasMention
if !babble.HasMention("Alice") {
t.Error("Expected message to mention Alice")
}
if !babble.HasMention("alice") { // Case insensitive
t.Error("Expected HasMention to be case insensitive")
}
if babble.HasMention("Bob") {
t.Error("Expected message not to mention Bob")
}
// Test message with multiple mentions and punctuation
babble.Babble = "Hey @Alice, @Bob! Can you help @Charlie?"
mentions = babble.GetMentions()
expectedMentions = []string{"Alice", "Bob", "Charlie"}
if len(mentions) != len(expectedMentions) {
t.Errorf("Expected %d mentions, got %d: %v", len(expectedMentions), len(mentions), mentions)
}
for _, expected := range expectedMentions {
if !babble.HasMention(expected) {
t.Errorf("Expected message to mention %s", expected)
}
}
// Test message with no mentions
babble.Babble = "No mentions in this message"
mentions = babble.GetMentions()
if len(mentions) != 0 {
t.Errorf("Expected 0 mentions, got %d", len(mentions))
}
// Test malformed mentions (should be ignored)
babble.Babble = "Just @ alone or @"
mentions = babble.GetMentions()
if len(mentions) != 0 {
t.Errorf("Expected 0 mentions for malformed @, got %d", len(mentions))
}
}

View File

@ -0,0 +1,90 @@
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
db *database.DB
}
// NewBuilder creates a new babble builder
func NewBuilder(db *database.DB) *Builder {
return &Builder{
babble: &Babble{
db: db,
Posted: time.Now().Unix(), // Default to current time
},
db: db,
}
}
// 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 := b.db.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
}

514
internal/babble/doc.go Normal file
View File

@ -0,0 +1,514 @@
/*
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

203
internal/control/control.go Normal file
View File

@ -0,0 +1,203 @@
package control
import (
"fmt"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Control represents the game control settings in the database
// There is only ever one control record with ID 1
type Control struct {
ID int `json:"id"`
WorldSize int `json:"world_size"`
Open int `json:"open"`
AdminEmail string `json:"admin_email"`
Class1Name string `json:"class_1_name"`
Class2Name string `json:"class_2_name"`
Class3Name string `json:"class_3_name"`
db *database.DB
}
// Find retrieves the control record by ID (typically only ID 1 exists)
func Find(db *database.DB, id int) (*Control, error) {
control := &Control{db: db}
query := "SELECT id, world_size, open, admin_email, class_1_name, class_2_name, class_3_name FROM control WHERE id = ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
control.ID = stmt.ColumnInt(0)
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
}, id)
if err != nil {
return nil, fmt.Errorf("failed to find control: %w", err)
}
if control.ID == 0 {
return nil, fmt.Errorf("control with ID %d not found", id)
}
return control, nil
}
// Get retrieves the main control record (ID 1)
func Get(db *database.DB) (*Control, error) {
return Find(db, 1)
}
// Save updates the control record in the database
func (c *Control) Save() error {
if c.ID == 0 {
return fmt.Errorf("cannot save control without ID")
}
query := `UPDATE control SET world_size = ?, open = ?, admin_email = ?, class_1_name = ?, class_2_name = ?, class_3_name = ? WHERE id = ?`
return c.db.Exec(query, c.WorldSize, c.Open, c.AdminEmail, c.Class1Name, c.Class2Name, c.Class3Name, c.ID)
}
// IsOpen returns true if the game world is open for new players
func (c *Control) IsOpen() bool {
return c.Open == 1
}
// SetOpen sets whether the game world is open for new players
func (c *Control) SetOpen(open bool) {
if open {
c.Open = 1
} else {
c.Open = 0
}
}
// Close closes the game world to new players
func (c *Control) Close() {
c.Open = 0
}
// OpenWorld opens the game world to new players
func (c *Control) OpenWorld() {
c.Open = 1
}
// GetClassNames returns all class names as a slice
func (c *Control) GetClassNames() []string {
classes := make([]string, 0, 3)
if c.Class1Name != "" {
classes = append(classes, c.Class1Name)
}
if c.Class2Name != "" {
classes = append(classes, c.Class2Name)
}
if c.Class3Name != "" {
classes = append(classes, c.Class3Name)
}
return classes
}
// SetClassNames sets all class names from a slice
func (c *Control) SetClassNames(classes []string) {
// Reset all class names
c.Class1Name = ""
c.Class2Name = ""
c.Class3Name = ""
// Set provided class names
if len(classes) > 0 {
c.Class1Name = classes[0]
}
if len(classes) > 1 {
c.Class2Name = classes[1]
}
if len(classes) > 2 {
c.Class3Name = classes[2]
}
}
// GetClassName returns the name of a specific class (1-3)
func (c *Control) GetClassName(classNum int) string {
switch classNum {
case 1:
return c.Class1Name
case 2:
return c.Class2Name
case 3:
return c.Class3Name
default:
return ""
}
}
// SetClassName sets the name of a specific class (1-3)
func (c *Control) SetClassName(classNum int, name string) bool {
switch classNum {
case 1:
c.Class1Name = name
return true
case 2:
c.Class2Name = name
return true
case 3:
c.Class3Name = name
return true
default:
return false
}
}
// IsValidClassName returns true if the given name matches one of the configured classes
func (c *Control) IsValidClassName(name string) bool {
if name == "" {
return false
}
return name == c.Class1Name || name == c.Class2Name || name == c.Class3Name
}
// GetClassNumber returns the class number (1-3) for a given class name, or 0 if not found
func (c *Control) GetClassNumber(name string) int {
if name == c.Class1Name && name != "" {
return 1
}
if name == c.Class2Name && name != "" {
return 2
}
if name == c.Class3Name && name != "" {
return 3
}
return 0
}
// HasAdminEmail returns true if an admin email is configured
func (c *Control) HasAdminEmail() bool {
return c.AdminEmail != ""
}
// IsWorldSizeValid returns true if the world size is within reasonable bounds
func (c *Control) IsWorldSizeValid() bool {
return c.WorldSize > 0 && c.WorldSize <= 10000
}
// GetWorldRadius returns the world radius (half the world size)
func (c *Control) GetWorldRadius() int {
return c.WorldSize / 2
}
// IsWithinWorldBounds returns true if the given coordinates are within world bounds
func (c *Control) IsWithinWorldBounds(x, y int) bool {
radius := c.GetWorldRadius()
return x >= -radius && x <= radius && y >= -radius && y <= radius
}
// GetWorldBounds returns the minimum and maximum coordinates for the world
func (c *Control) GetWorldBounds() (minX, minY, maxX, maxY int) {
radius := c.GetWorldRadius()
return -radius, -radius, radius, radius
}

View File

@ -0,0 +1,418 @@
package control
import (
"os"
"testing"
"dk/internal/database"
)
func setupTestDB(t *testing.T) *database.DB {
testDB := "test_control.db"
t.Cleanup(func() {
os.Remove(testDB)
})
db, err := database.Open(testDB)
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
// Create control table
createTable := `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 ''
)`
if err := db.Exec(createTable); err != nil {
t.Fatalf("Failed to create control table: %v", err)
}
// Insert default control record
insertControl := `INSERT INTO control VALUES (1, 250, 1, 'admin@example.com', 'Mage', 'Warrior', 'Paladin')`
if err := db.Exec(insertControl); err != nil {
t.Fatalf("Failed to insert test control: %v", err)
}
return db
}
func TestFind(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test finding existing control record
control, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find control: %v", err)
}
if control.ID != 1 {
t.Errorf("Expected ID 1, got %d", control.ID)
}
if control.WorldSize != 250 {
t.Errorf("Expected world size 250, got %d", control.WorldSize)
}
if control.Open != 1 {
t.Errorf("Expected open 1, got %d", control.Open)
}
if control.AdminEmail != "admin@example.com" {
t.Errorf("Expected admin email 'admin@example.com', got '%s'", control.AdminEmail)
}
if control.Class1Name != "Mage" {
t.Errorf("Expected class 1 name 'Mage', got '%s'", control.Class1Name)
}
if control.Class2Name != "Warrior" {
t.Errorf("Expected class 2 name 'Warrior', got '%s'", control.Class2Name)
}
if control.Class3Name != "Paladin" {
t.Errorf("Expected class 3 name 'Paladin', got '%s'", control.Class3Name)
}
// Test finding non-existent control record
_, err = Find(db, 999)
if err == nil {
t.Error("Expected error when finding non-existent control")
}
}
func TestGet(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test getting main control record
control, err := Get(db)
if err != nil {
t.Fatalf("Failed to get control: %v", err)
}
if control.ID != 1 {
t.Errorf("Expected ID 1, got %d", control.ID)
}
if control.WorldSize != 250 {
t.Errorf("Expected world size 250, got %d", control.WorldSize)
}
}
func TestSave(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
control, err := Get(db)
if err != nil {
t.Fatalf("Failed to get control: %v", err)
}
// Modify control settings
control.WorldSize = 500
control.Open = 0
control.AdminEmail = "newadmin@example.com"
control.Class1Name = "Wizard"
control.Class2Name = "Knight"
control.Class3Name = "Cleric"
// Save changes
err = control.Save()
if err != nil {
t.Fatalf("Failed to save control: %v", err)
}
// Verify changes were saved
updatedControl, err := Get(db)
if err != nil {
t.Fatalf("Failed to get updated control: %v", err)
}
if updatedControl.WorldSize != 500 {
t.Errorf("Expected updated world size 500, got %d", updatedControl.WorldSize)
}
if updatedControl.Open != 0 {
t.Errorf("Expected updated open 0, got %d", updatedControl.Open)
}
if updatedControl.AdminEmail != "newadmin@example.com" {
t.Errorf("Expected updated admin email 'newadmin@example.com', got '%s'", updatedControl.AdminEmail)
}
if updatedControl.Class1Name != "Wizard" {
t.Errorf("Expected updated class 1 name 'Wizard', got '%s'", updatedControl.Class1Name)
}
if updatedControl.Class2Name != "Knight" {
t.Errorf("Expected updated class 2 name 'Knight', got '%s'", updatedControl.Class2Name)
}
if updatedControl.Class3Name != "Cleric" {
t.Errorf("Expected updated class 3 name 'Cleric', got '%s'", updatedControl.Class3Name)
}
}
func TestOpenMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
control, _ := Get(db)
// Test IsOpen
if !control.IsOpen() {
t.Error("Expected control to be open initially")
}
// Test SetOpen
control.SetOpen(false)
if control.IsOpen() {
t.Error("Expected control to be closed after SetOpen(false)")
}
control.SetOpen(true)
if !control.IsOpen() {
t.Error("Expected control to be open after SetOpen(true)")
}
// Test Close
control.Close()
if control.IsOpen() {
t.Error("Expected control to be closed after Close()")
}
// Test OpenWorld
control.OpenWorld()
if !control.IsOpen() {
t.Error("Expected control to be open after OpenWorld()")
}
}
func TestClassMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
control, _ := Get(db)
// Test GetClassNames
classNames := control.GetClassNames()
expectedNames := []string{"Mage", "Warrior", "Paladin"}
if len(classNames) != len(expectedNames) {
t.Errorf("Expected %d class names, got %d", len(expectedNames), len(classNames))
}
for i, expected := range expectedNames {
if i < len(classNames) && classNames[i] != expected {
t.Errorf("Expected class name '%s' at position %d, got '%s'", expected, i, classNames[i])
}
}
// Test SetClassNames
newClasses := []string{"Sorcerer", "Barbarian", "Monk"}
control.SetClassNames(newClasses)
if control.Class1Name != "Sorcerer" {
t.Errorf("Expected class 1 name 'Sorcerer', got '%s'", control.Class1Name)
}
if control.Class2Name != "Barbarian" {
t.Errorf("Expected class 2 name 'Barbarian', got '%s'", control.Class2Name)
}
if control.Class3Name != "Monk" {
t.Errorf("Expected class 3 name 'Monk', got '%s'", control.Class3Name)
}
// Test SetClassNames with fewer than 3 classes
twoClasses := []string{"Ranger", "Druid"}
control.SetClassNames(twoClasses)
if control.Class1Name != "Ranger" {
t.Errorf("Expected class 1 name 'Ranger', got '%s'", control.Class1Name)
}
if control.Class2Name != "Druid" {
t.Errorf("Expected class 2 name 'Druid', got '%s'", control.Class2Name)
}
if control.Class3Name != "" {
t.Errorf("Expected class 3 name to be empty, got '%s'", control.Class3Name)
}
// Test GetClassName
if control.GetClassName(1) != "Ranger" {
t.Errorf("Expected class 1 name 'Ranger', got '%s'", control.GetClassName(1))
}
if control.GetClassName(2) != "Druid" {
t.Errorf("Expected class 2 name 'Druid', got '%s'", control.GetClassName(2))
}
if control.GetClassName(3) != "" {
t.Errorf("Expected class 3 name to be empty, got '%s'", control.GetClassName(3))
}
if control.GetClassName(4) != "" {
t.Errorf("Expected invalid class number to return empty string, got '%s'", control.GetClassName(4))
}
// Test SetClassName
if !control.SetClassName(3, "Rogue") {
t.Error("Expected SetClassName(3, 'Rogue') to return true")
}
if control.Class3Name != "Rogue" {
t.Errorf("Expected class 3 name 'Rogue', got '%s'", control.Class3Name)
}
if control.SetClassName(4, "Invalid") {
t.Error("Expected SetClassName(4, 'Invalid') to return false")
}
// Test IsValidClassName
if !control.IsValidClassName("Ranger") {
t.Error("Expected 'Ranger' to be a valid class name")
}
if !control.IsValidClassName("Druid") {
t.Error("Expected 'Druid' to be a valid class name")
}
if !control.IsValidClassName("Rogue") {
t.Error("Expected 'Rogue' to be a valid class name")
}
if control.IsValidClassName("Bard") {
t.Error("Expected 'Bard' not to be a valid class name")
}
if control.IsValidClassName("") {
t.Error("Expected empty string not to be a valid class name")
}
// Test GetClassNumber
if control.GetClassNumber("Ranger") != 1 {
t.Errorf("Expected 'Ranger' to be class number 1, got %d", control.GetClassNumber("Ranger"))
}
if control.GetClassNumber("Druid") != 2 {
t.Errorf("Expected 'Druid' to be class number 2, got %d", control.GetClassNumber("Druid"))
}
if control.GetClassNumber("Rogue") != 3 {
t.Errorf("Expected 'Rogue' to be class number 3, got %d", control.GetClassNumber("Rogue"))
}
if control.GetClassNumber("Bard") != 0 {
t.Errorf("Expected 'Bard' to return class number 0, got %d", control.GetClassNumber("Bard"))
}
if control.GetClassNumber("") != 0 {
t.Errorf("Expected empty string to return class number 0, got %d", control.GetClassNumber(""))
}
}
func TestEmailMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
control, _ := Get(db)
// Test HasAdminEmail (should be true initially)
if !control.HasAdminEmail() {
t.Error("Expected control to have admin email initially")
}
// Test with empty email
control.AdminEmail = ""
if control.HasAdminEmail() {
t.Error("Expected control not to have admin email when empty")
}
}
func TestWorldSizeMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
control, _ := Get(db)
// Test IsWorldSizeValid
if !control.IsWorldSizeValid() {
t.Error("Expected world size 250 to be valid")
}
control.WorldSize = 0
if control.IsWorldSizeValid() {
t.Error("Expected world size 0 to be invalid")
}
control.WorldSize = 10001
if control.IsWorldSizeValid() {
t.Error("Expected world size 10001 to be invalid")
}
control.WorldSize = 1000
if !control.IsWorldSizeValid() {
t.Error("Expected world size 1000 to be valid")
}
// Test GetWorldRadius
expectedRadius := 500
if control.GetWorldRadius() != expectedRadius {
t.Errorf("Expected world radius %d, got %d", expectedRadius, control.GetWorldRadius())
}
// Test IsWithinWorldBounds
if !control.IsWithinWorldBounds(0, 0) {
t.Error("Expected (0,0) to be within world bounds")
}
if !control.IsWithinWorldBounds(500, 500) {
t.Error("Expected (500,500) to be within world bounds")
}
if !control.IsWithinWorldBounds(-500, -500) {
t.Error("Expected (-500,-500) to be within world bounds")
}
if control.IsWithinWorldBounds(501, 0) {
t.Error("Expected (501,0) to be outside world bounds")
}
if control.IsWithinWorldBounds(0, 501) {
t.Error("Expected (0,501) to be outside world bounds")
}
if control.IsWithinWorldBounds(-501, 0) {
t.Error("Expected (-501,0) to be outside world bounds")
}
if control.IsWithinWorldBounds(0, -501) {
t.Error("Expected (0,-501) to be outside world bounds")
}
// Test GetWorldBounds
minX, minY, maxX, maxY := control.GetWorldBounds()
expectedMin, expectedMax := -500, 500
if minX != expectedMin {
t.Errorf("Expected minX %d, got %d", expectedMin, minX)
}
if minY != expectedMin {
t.Errorf("Expected minY %d, got %d", expectedMin, minY)
}
if maxX != expectedMax {
t.Errorf("Expected maxX %d, got %d", expectedMax, maxX)
}
if maxY != expectedMax {
t.Errorf("Expected maxY %d, got %d", expectedMax, maxY)
}
}
func TestEmptyClassHandling(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
control, _ := Get(db)
// Set some classes to empty
control.Class2Name = ""
control.Class3Name = ""
// Test GetClassNames with empty classes
classNames := control.GetClassNames()
expectedCount := 1 // Only Class1Name should be included
if len(classNames) != expectedCount {
t.Errorf("Expected %d non-empty class names, got %d", expectedCount, len(classNames))
}
if len(classNames) > 0 && classNames[0] != "Mage" {
t.Errorf("Expected first class name 'Mage', got '%s'", classNames[0])
}
// Test IsValidClassName with empty string
if control.IsValidClassName("") {
t.Error("Expected empty string not to be valid class name")
}
// Test GetClassNumber with empty class names
if control.GetClassNumber("Warrior") != 0 {
t.Error("Expected empty class 2 not to match 'Warrior'")
}
}

416
internal/control/doc.go Normal file
View File

@ -0,0 +1,416 @@
/*
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

88
internal/drops/builder.go Normal file
View File

@ -0,0 +1,88 @@
package drops
import (
"dk/internal/database"
"fmt"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating drops
type Builder struct {
drop *Drop
db *database.DB
}
// NewBuilder creates a new drop builder
func NewBuilder(db *database.DB) *Builder {
return &Builder{
drop: &Drop{db: db},
db: db,
}
}
// 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 := b.db.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,
db: b.db,
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to create drop: %w", err)
}
return drop, nil
}

147
internal/drops/doc.go Normal file
View File

@ -0,0 +1,147 @@
/*
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

160
internal/drops/drops.go Normal file
View File

@ -0,0 +1,160 @@
package drops
import (
"fmt"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Drop represents a drop item in the database
type Drop struct {
ID int `json:"id"`
Name string `json:"name"`
Level int `json:"level"`
Type int `json:"type"`
Att string `json:"att"`
db *database.DB
}
// DropType constants for drop types
const (
TypeConsumable = 1
)
// Find retrieves a drop by ID
func Find(db *database.DB, id int) (*Drop, error) {
drop := &Drop{db: db}
query := "SELECT id, name, level, type, att FROM drops WHERE id = ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
drop.ID = stmt.ColumnInt(0)
drop.Name = stmt.ColumnText(1)
drop.Level = stmt.ColumnInt(2)
drop.Type = stmt.ColumnInt(3)
drop.Att = stmt.ColumnText(4)
return nil
}, id)
if err != nil {
return nil, fmt.Errorf("failed to find drop: %w", err)
}
if drop.ID == 0 {
return nil, fmt.Errorf("drop with ID %d not found", id)
}
return drop, nil
}
// All retrieves all drops
func All(db *database.DB) ([]*Drop, error) {
var drops []*Drop
query := "SELECT id, name, level, type, att FROM drops ORDER BY id"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
drop := &Drop{
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
Level: stmt.ColumnInt(2),
Type: stmt.ColumnInt(3),
Att: stmt.ColumnText(4),
db: db,
}
drops = append(drops, drop)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to retrieve all drops: %w", err)
}
return drops, nil
}
// ByLevel retrieves drops by minimum level requirement
func ByLevel(db *database.DB, minLevel int) ([]*Drop, error) {
var drops []*Drop
query := "SELECT id, name, level, type, att FROM drops WHERE level <= ? ORDER BY level, id"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
drop := &Drop{
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
Level: stmt.ColumnInt(2),
Type: stmt.ColumnInt(3),
Att: stmt.ColumnText(4),
db: db,
}
drops = append(drops, drop)
return nil
}, minLevel)
if err != nil {
return nil, fmt.Errorf("failed to retrieve drops by level: %w", err)
}
return drops, nil
}
// ByType retrieves drops by type
func ByType(db *database.DB, dropType int) ([]*Drop, error) {
var drops []*Drop
query := "SELECT id, name, level, type, att FROM drops WHERE type = ? ORDER BY level, id"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
drop := &Drop{
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
Level: stmt.ColumnInt(2),
Type: stmt.ColumnInt(3),
Att: stmt.ColumnText(4),
db: db,
}
drops = append(drops, drop)
return nil
}, dropType)
if err != nil {
return nil, fmt.Errorf("failed to retrieve drops by type: %w", err)
}
return drops, nil
}
// Save updates an existing drop in the database
func (d *Drop) Save() error {
if d.ID == 0 {
return fmt.Errorf("cannot save drop without ID")
}
query := `UPDATE drops SET name = ?, level = ?, type = ?, att = ? WHERE id = ?`
return d.db.Exec(query, d.Name, d.Level, d.Type, d.Att, d.ID)
}
// Delete removes the drop from the database
func (d *Drop) Delete() error {
if d.ID == 0 {
return fmt.Errorf("cannot delete drop without ID")
}
query := "DELETE FROM drops WHERE id = ?"
return d.db.Exec(query, d.ID)
}
// IsConsumable returns true if the drop is a consumable item
func (d *Drop) IsConsumable() bool {
return d.Type == TypeConsumable
}
// TypeName returns the string representation of the drop type
func (d *Drop) TypeName() string {
switch d.Type {
case TypeConsumable:
return "Consumable"
default:
return "Unknown"
}
}

View File

@ -0,0 +1,270 @@
package drops
import (
"os"
"testing"
"dk/internal/database"
)
func setupTestDB(t *testing.T) *database.DB {
testDB := "test_drops.db"
t.Cleanup(func() {
os.Remove(testDB)
})
db, err := database.Open(testDB)
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
// Create drops table
createTable := `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 ''
)`
if err := db.Exec(createTable); err != nil {
t.Fatalf("Failed to create drops table: %v", err)
}
// Insert test data
testDrops := `INSERT INTO drops (name, level, type, att) VALUES
('Life Pebble', 1, 1, 'maxhp,10'),
('Magic Stone', 10, 1, 'maxmp,25'),
('Dragon''s Scale', 10, 1, 'defensepower,25'),
('Angel''s Joy', 25, 1, 'maxhp,25,strength,25')`
if err := db.Exec(testDrops); err != nil {
t.Fatalf("Failed to insert test drops: %v", err)
}
return db
}
func TestFind(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test finding existing drop
drop, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find drop: %v", err)
}
if drop.ID != 1 {
t.Errorf("Expected ID 1, got %d", drop.ID)
}
if drop.Name != "Life Pebble" {
t.Errorf("Expected name 'Life Pebble', got '%s'", drop.Name)
}
if drop.Level != 1 {
t.Errorf("Expected level 1, got %d", drop.Level)
}
if drop.Type != TypeConsumable {
t.Errorf("Expected type %d, got %d", TypeConsumable, drop.Type)
}
if drop.Att != "maxhp,10" {
t.Errorf("Expected att1 'maxhp,10', got '%s'", drop.Att)
}
// Test finding non-existent drop
_, err = Find(db, 999)
if err == nil {
t.Error("Expected error when finding non-existent drop")
}
}
func TestAll(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
drops, err := All(db)
if err != nil {
t.Fatalf("Failed to get all drops: %v", err)
}
if len(drops) != 4 {
t.Errorf("Expected 4 drops, got %d", len(drops))
}
// Check first drop
if drops[0].Name != "Life Pebble" {
t.Errorf("Expected first drop to be 'Life Pebble', got '%s'", drops[0].Name)
}
}
func TestByLevel(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test drops available at level 10
drops, err := ByLevel(db, 10)
if err != nil {
t.Fatalf("Failed to get drops by level: %v", err)
}
if len(drops) != 3 {
t.Errorf("Expected 3 drops at level 10, got %d", len(drops))
}
// Verify they are ordered by level
if drops[0].Level != 1 {
t.Errorf("Expected first drop level 1, got %d", drops[0].Level)
}
if drops[1].Level != 10 {
t.Errorf("Expected second drop level 10, got %d", drops[1].Level)
}
// Test drops available at level 1
lowLevelDrops, err := ByLevel(db, 1)
if err != nil {
t.Fatalf("Failed to get drops by level 1: %v", err)
}
if len(lowLevelDrops) != 1 {
t.Errorf("Expected 1 drop at level 1, got %d", len(lowLevelDrops))
}
}
func TestByType(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
consumables, err := ByType(db, TypeConsumable)
if err != nil {
t.Fatalf("Failed to get consumable drops: %v", err)
}
if len(consumables) != 4 {
t.Errorf("Expected 4 consumable drops, got %d", len(consumables))
}
// Verify they are ordered by level, then ID
if consumables[0].Level > consumables[1].Level {
t.Error("Expected drops to be ordered by level")
}
}
func TestBuilder(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Create new drop using builder
drop, err := NewBuilder(db).
WithName("Test Drop").
WithLevel(15).
WithType(TypeConsumable).
WithAtt("strength,20,dexterity,15").
Create()
if err != nil {
t.Fatalf("Failed to create drop with builder: %v", err)
}
if drop.ID == 0 {
t.Error("Expected non-zero ID after creation")
}
if drop.Name != "Test Drop" {
t.Errorf("Expected name 'Test Drop', got '%s'", drop.Name)
}
if drop.Level != 15 {
t.Errorf("Expected level 15, got %d", drop.Level)
}
if drop.Type != TypeConsumable {
t.Errorf("Expected type %d, got %d", TypeConsumable, drop.Type)
}
if drop.Att != "strength,20,dexterity,15" {
t.Errorf("Expected att 'strength,20,dexterity,15', got '%s'", drop.Att)
}
// Verify it was saved to database
foundDrop, err := Find(db, drop.ID)
if err != nil {
t.Fatalf("Failed to find created drop: %v", err)
}
if foundDrop.Name != "Test Drop" {
t.Errorf("Created drop not found in database")
}
}
func TestSave(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
drop, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find drop: %v", err)
}
// Modify drop
drop.Name = "Updated Life Pebble"
drop.Level = 5
drop.Att = "maxhp,15"
// Save changes
err = drop.Save()
if err != nil {
t.Fatalf("Failed to save drop: %v", err)
}
// Verify changes were saved
updatedDrop, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find updated drop: %v", err)
}
if updatedDrop.Name != "Updated Life Pebble" {
t.Errorf("Expected updated name 'Updated Life Pebble', got '%s'", updatedDrop.Name)
}
if updatedDrop.Level != 5 {
t.Errorf("Expected updated level 5, got %d", updatedDrop.Level)
}
if updatedDrop.Att != "maxhp,15" {
t.Errorf("Expected updated att 'maxhp,15', got '%s'", updatedDrop.Att)
}
}
func TestDelete(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
drop, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find drop: %v", err)
}
// Delete drop
err = drop.Delete()
if err != nil {
t.Fatalf("Failed to delete drop: %v", err)
}
// Verify drop was deleted
_, err = Find(db, 1)
if err == nil {
t.Error("Expected error when finding deleted drop")
}
}
func TestDropMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
pebble, _ := Find(db, 1)
// Test IsConsumable
if !pebble.IsConsumable() {
t.Error("Expected pebble to be consumable")
}
// Test TypeName
if pebble.TypeName() != "Consumable" {
t.Errorf("Expected pebble type name 'Consumable', got '%s'", pebble.TypeName())
}
}

131
internal/forum/builder.go Normal file
View File

@ -0,0 +1,131 @@
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
db *database.DB
}
// NewBuilder creates a new forum post builder
func NewBuilder(db *database.DB) *Builder {
now := time.Now().Unix()
return &Builder{
forum: &Forum{
db: db,
Posted: now,
LastPost: now, // Default to same as posted time
Parent: 0, // Default to thread (no parent)
Replies: 0, // Default to no replies
},
db: db,
}
}
// 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 := b.db.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
}

627
internal/forum/doc.go Normal file
View File

@ -0,0 +1,627 @@
/*
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

409
internal/forum/forum.go Normal file
View File

@ -0,0 +1,409 @@
package forum
import (
"fmt"
"strings"
"time"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Forum represents a forum post or thread in the database
type Forum struct {
ID int `json:"id"`
Posted int64 `json:"posted"`
LastPost int64 `json:"last_post"`
Author int `json:"author"`
Parent int `json:"parent"`
Replies int `json:"replies"`
Title string `json:"title"`
Content string `json:"content"`
db *database.DB
}
// Find retrieves a forum post by ID
func Find(db *database.DB, id int) (*Forum, error) {
forum := &Forum{db: db}
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE id = ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum.ID = stmt.ColumnInt(0)
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
}, id)
if err != nil {
return nil, fmt.Errorf("failed to find forum post: %w", err)
}
if forum.ID == 0 {
return nil, fmt.Errorf("forum post with ID %d not found", id)
}
return forum, nil
}
// All retrieves all forum posts ordered by last post time (most recent first)
func All(db *database.DB) ([]*Forum, error) {
var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum ORDER BY last_post DESC, id DESC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{
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),
db: db,
}
forums = append(forums, forum)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to retrieve all forum posts: %w", err)
}
return forums, nil
}
// Threads retrieves all top-level forum threads (parent = 0)
func Threads(db *database.DB) ([]*Forum, error) {
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"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{
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),
db: db,
}
forums = append(forums, forum)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to retrieve forum threads: %w", err)
}
return forums, nil
}
// ByParent retrieves all replies to a specific thread/post
func ByParent(db *database.DB, parentID int) ([]*Forum, error) {
var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE parent = ? ORDER BY posted ASC, id ASC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{
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),
db: db,
}
forums = append(forums, forum)
return nil
}, parentID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve forum replies: %w", err)
}
return forums, nil
}
// ByAuthor retrieves forum posts by a specific author
func ByAuthor(db *database.DB, authorID int) ([]*Forum, error) {
var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE author = ? ORDER BY posted DESC, id DESC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{
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),
db: db,
}
forums = append(forums, forum)
return nil
}, authorID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve forum posts by author: %w", err)
}
return forums, nil
}
// Recent retrieves the most recent forum activity (limited by count)
func Recent(db *database.DB, limit int) ([]*Forum, error) {
var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum ORDER BY last_post DESC, id DESC LIMIT ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{
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),
db: db,
}
forums = append(forums, forum)
return nil
}, limit)
if err != nil {
return nil, fmt.Errorf("failed to retrieve recent forum posts: %w", err)
}
return forums, nil
}
// Search retrieves forum posts containing the search term in title or content
func Search(db *database.DB, term string) ([]*Forum, error) {
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"
searchTerm := "%" + term + "%"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{
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),
db: db,
}
forums = append(forums, forum)
return nil
}, searchTerm, searchTerm)
if err != nil {
return nil, fmt.Errorf("failed to search forum posts: %w", err)
}
return forums, nil
}
// Since retrieves forum posts with activity since a specific timestamp
func Since(db *database.DB, since int64) ([]*Forum, error) {
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"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{
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),
db: db,
}
forums = append(forums, forum)
return nil
}, since)
if err != nil {
return nil, fmt.Errorf("failed to retrieve forum posts since timestamp: %w", err)
}
return forums, nil
}
// Save updates an existing forum post in the database
func (f *Forum) Save() error {
if f.ID == 0 {
return fmt.Errorf("cannot save forum post without ID")
}
query := `UPDATE forum SET posted = ?, last_post = ?, author = ?, parent = ?, replies = ?, title = ?, content = ? WHERE id = ?`
return f.db.Exec(query, f.Posted, f.LastPost, f.Author, f.Parent, f.Replies, f.Title, f.Content, f.ID)
}
// Delete removes the forum post from the database
func (f *Forum) Delete() error {
if f.ID == 0 {
return fmt.Errorf("cannot delete forum post without ID")
}
query := "DELETE FROM forum WHERE id = ?"
return f.db.Exec(query, f.ID)
}
// PostedTime returns the posted timestamp as a time.Time
func (f *Forum) PostedTime() time.Time {
return time.Unix(f.Posted, 0)
}
// LastPostTime returns the last post timestamp as a time.Time
func (f *Forum) LastPostTime() time.Time {
return time.Unix(f.LastPost, 0)
}
// SetPostedTime sets the posted timestamp from a time.Time
func (f *Forum) SetPostedTime(t time.Time) {
f.Posted = t.Unix()
}
// SetLastPostTime sets the last post timestamp from a time.Time
func (f *Forum) SetLastPostTime(t time.Time) {
f.LastPost = t.Unix()
}
// IsThread returns true if this is a top-level thread (parent = 0)
func (f *Forum) IsThread() bool {
return f.Parent == 0
}
// IsReply returns true if this is a reply to another post (parent > 0)
func (f *Forum) IsReply() bool {
return f.Parent > 0
}
// HasReplies returns true if this post has replies
func (f *Forum) HasReplies() bool {
return f.Replies > 0
}
// IsRecentActivity returns true if there has been activity within the last 24 hours
func (f *Forum) IsRecentActivity() bool {
return time.Since(f.LastPostTime()) < 24*time.Hour
}
// ActivityAge returns how long ago the last activity occurred
func (f *Forum) ActivityAge() time.Duration {
return time.Since(f.LastPostTime())
}
// PostAge returns how long ago the post was originally made
func (f *Forum) PostAge() time.Duration {
return time.Since(f.PostedTime())
}
// IsAuthor returns true if the given user ID is the author of this post
func (f *Forum) IsAuthor(userID int) bool {
return f.Author == userID
}
// Preview returns a truncated version of the content for previews
func (f *Forum) Preview(maxLength int) string {
if len(f.Content) <= maxLength {
return f.Content
}
if maxLength < 3 {
return f.Content[:maxLength]
}
return f.Content[:maxLength-3] + "..."
}
// WordCount returns the number of words in the content
func (f *Forum) WordCount() int {
if f.Content == "" {
return 0
}
// Simple word count by splitting on whitespace
words := 0
inWord := false
for _, char := range f.Content {
if char == ' ' || char == '\t' || char == '\n' || char == '\r' {
if inWord {
words++
inWord = false
}
} else {
inWord = true
}
}
if inWord {
words++
}
return words
}
// Length returns the character length of the content
func (f *Forum) Length() int {
return len(f.Content)
}
// Contains returns true if the title or content contains the given term (case-insensitive)
func (f *Forum) Contains(term string) bool {
lowerTerm := strings.ToLower(term)
return strings.Contains(strings.ToLower(f.Title), lowerTerm) ||
strings.Contains(strings.ToLower(f.Content), lowerTerm)
}
// UpdateLastPost updates the last_post timestamp to current time
func (f *Forum) UpdateLastPost() {
f.LastPost = time.Now().Unix()
}
// IncrementReplies increments the reply count
func (f *Forum) IncrementReplies() {
f.Replies++
}
// DecrementReplies decrements the reply count (minimum 0)
func (f *Forum) DecrementReplies() {
if f.Replies > 0 {
f.Replies--
}
}
// GetReplies retrieves all direct replies to this post
func (f *Forum) GetReplies() ([]*Forum, error) {
return ByParent(f.db, f.ID)
}
// GetThread retrieves the parent thread (if this is a reply) or returns self (if this is a thread)
func (f *Forum) GetThread() (*Forum, error) {
if f.IsThread() {
return f, nil
}
return Find(f.db, f.Parent)
}

View File

@ -0,0 +1,665 @@
package forum
import (
"os"
"testing"
"time"
"dk/internal/database"
)
func setupTestDB(t *testing.T) *database.DB {
testDB := "test_forum.db"
t.Cleanup(func() {
os.Remove(testDB)
})
db, err := database.Open(testDB)
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
// Create forum table
createTable := `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
)`
if err := db.Exec(createTable); err != nil {
t.Fatalf("Failed to create forum table: %v", err)
}
// Insert test data with specific timestamps for predictable testing
now := time.Now().Unix()
testForum := `INSERT INTO forum (posted, last_post, author, parent, replies, title, content) VALUES
(?, ?, 1, 0, 2, 'Welcome to the Game!', 'This is the first thread about our awesome game.'),
(?, ?, 2, 1, 0, 'Re: Welcome to the Game!', 'Thanks! I am excited to start playing.'),
(?, ?, 3, 1, 0, 'Re: Welcome to the Game!', 'Great game so far, loving the mechanics!'),
(?, ?, 1, 0, 1, 'Bug Reports', 'Please report any bugs you find here.'),
(?, ?, 2, 4, 0, 'Re: Bug Reports', 'Found a small issue with spell casting.'),
(?, ?, 3, 0, 0, 'Strategy Discussion', 'Let us discuss optimal character builds and strategies.')`
timestamps := []interface{}{
now - 86400*7, now - 86400*1, // Thread 1, last activity 1 day ago
now - 86400*6, now - 86400*6, // Reply 1
now - 86400*1, now - 86400*1, // Reply 2 (most recent activity on thread 1)
now - 86400*3, now - 86400*2, // Thread 2, last activity 2 days ago
now - 86400*2, now - 86400*2, // Reply to thread 2 (most recent activity on thread 2)
now - 3600*2, now - 3600*2, // Thread 3, 2 hours ago (most recent)
}
if err := db.Exec(testForum, timestamps...); err != nil {
t.Fatalf("Failed to insert test forum data: %v", err)
}
return db
}
func TestFind(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test finding existing forum post
post, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find forum post: %v", err)
}
if post.ID != 1 {
t.Errorf("Expected ID 1, got %d", post.ID)
}
if post.Author != 1 {
t.Errorf("Expected author 1, got %d", post.Author)
}
if post.Parent != 0 {
t.Errorf("Expected parent 0, got %d", post.Parent)
}
if post.Replies != 2 {
t.Errorf("Expected replies 2, got %d", post.Replies)
}
if post.Title != "Welcome to the Game!" {
t.Errorf("Expected title 'Welcome to the Game!', got '%s'", post.Title)
}
if post.Content != "This is the first thread about our awesome game." {
t.Errorf("Expected specific content, got '%s'", post.Content)
}
if post.Posted == 0 {
t.Error("Expected non-zero posted timestamp")
}
if post.LastPost == 0 {
t.Error("Expected non-zero last_post timestamp")
}
// Test finding non-existent forum post
_, err = Find(db, 999)
if err == nil {
t.Error("Expected error when finding non-existent forum post")
}
}
func TestAll(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
posts, err := All(db)
if err != nil {
t.Fatalf("Failed to get all forum posts: %v", err)
}
if len(posts) != 6 {
t.Errorf("Expected 6 forum posts, got %d", len(posts))
}
// Check ordering (by last_post DESC)
if len(posts) >= 2 {
if posts[0].LastPost < posts[1].LastPost {
t.Error("Expected posts to be ordered by last_post (newest first)")
}
}
// First post should be the most recent activity (2 hours ago)
if posts[0].Title != "Strategy Discussion" {
t.Errorf("Expected newest activity to be 'Strategy Discussion', got '%s'", posts[0].Title)
}
}
func TestThreads(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
threads, err := Threads(db)
if err != nil {
t.Fatalf("Failed to get forum threads: %v", err)
}
if len(threads) != 3 {
t.Errorf("Expected 3 threads, got %d", len(threads))
}
// Verify all are threads (parent = 0)
for _, thread := range threads {
if thread.Parent != 0 {
t.Errorf("Expected thread to have parent 0, got %d", thread.Parent)
}
if !thread.IsThread() {
t.Errorf("Expected IsThread() to return true for thread %d", thread.ID)
}
if thread.IsReply() {
t.Errorf("Expected IsReply() to return false for thread %d", thread.ID)
}
}
// Check ordering (by last_post DESC)
if len(threads) >= 2 {
if threads[0].LastPost < threads[1].LastPost {
t.Error("Expected threads to be ordered by last activity")
}
}
}
func TestByParent(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test replies to thread 1
replies, err := ByParent(db, 1)
if err != nil {
t.Fatalf("Failed to get replies: %v", err)
}
if len(replies) != 2 {
t.Errorf("Expected 2 replies to thread 1, got %d", len(replies))
}
// Verify all are replies to thread 1
for _, reply := range replies {
if reply.Parent != 1 {
t.Errorf("Expected reply to have parent 1, got %d", reply.Parent)
}
if !reply.IsReply() {
t.Errorf("Expected IsReply() to return true for reply %d", reply.ID)
}
if reply.IsThread() {
t.Errorf("Expected IsThread() to return false for reply %d", reply.ID)
}
}
// Check ordering (by posted ASC for replies)
if len(replies) == 2 {
if replies[0].Posted > replies[1].Posted {
t.Error("Expected replies to be ordered by posted time (oldest first)")
}
}
// Test no replies case
noReplies, err := ByParent(db, 6) // Thread 3 has no replies
if err != nil {
t.Fatalf("Failed to get replies for thread with no replies: %v", err)
}
if len(noReplies) != 0 {
t.Errorf("Expected 0 replies for thread 3, got %d", len(noReplies))
}
}
func TestByAuthor(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test posts by author 1
author1Posts, err := ByAuthor(db, 1)
if err != nil {
t.Fatalf("Failed to get posts by author: %v", err)
}
if len(author1Posts) != 2 {
t.Errorf("Expected 2 posts by author 1, got %d", len(author1Posts))
}
// Verify all posts are by author 1
for _, post := range author1Posts {
if post.Author != 1 {
t.Errorf("Expected author 1, got %d", post.Author)
}
}
// Check ordering (by posted DESC)
if len(author1Posts) == 2 {
if author1Posts[0].Posted < author1Posts[1].Posted {
t.Error("Expected posts to be ordered by posted time (newest first)")
}
}
// Test author with no posts
noPosts, err := ByAuthor(db, 999)
if err != nil {
t.Fatalf("Failed to query non-existent author: %v", err)
}
if len(noPosts) != 0 {
t.Errorf("Expected 0 posts by non-existent author, got %d", len(noPosts))
}
}
func TestRecent(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test getting 3 most recent posts
recentPosts, err := Recent(db, 3)
if err != nil {
t.Fatalf("Failed to get recent forum posts: %v", err)
}
if len(recentPosts) != 3 {
t.Errorf("Expected 3 recent posts, got %d", len(recentPosts))
}
// Check ordering (by last_post DESC)
if len(recentPosts) >= 2 {
if recentPosts[0].LastPost < recentPosts[1].LastPost {
t.Error("Expected recent posts to be ordered by last activity")
}
}
// Test getting more posts than exist
allRecentPosts, err := Recent(db, 10)
if err != nil {
t.Fatalf("Failed to get recent posts with high limit: %v", err)
}
if len(allRecentPosts) != 6 {
t.Errorf("Expected 6 posts (all available), got %d", len(allRecentPosts))
}
}
func TestSearch(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test searching for "game"
gamePosts, err := Search(db, "game")
if err != nil {
t.Fatalf("Failed to search forum posts: %v", err)
}
expectedCount := 3 // Welcome thread title, content, and replies containing "game"
if len(gamePosts) != expectedCount {
t.Errorf("Expected %d posts containing 'game', got %d", expectedCount, len(gamePosts))
}
// Verify all posts contain the search term
for _, post := range gamePosts {
if !post.Contains("game") {
t.Errorf("Post '%s' does not contain search term 'game'", post.Title)
}
}
// Test case insensitive search
gamePostsUpper, err := Search(db, "GAME")
if err != nil {
t.Fatalf("Failed to search with uppercase: %v", err)
}
if len(gamePostsUpper) != expectedCount {
t.Error("Expected case insensitive search to find same results")
}
// Test search with no results
noResults, err := Search(db, "nonexistentterm")
if err != nil {
t.Fatalf("Failed to search for non-existent term: %v", err)
}
if len(noResults) != 0 {
t.Errorf("Expected 0 results for non-existent term, got %d", len(noResults))
}
}
func TestSince(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test posts with activity since 3 days ago
threeDaysAgo := time.Now().AddDate(0, 0, -3).Unix()
recentPosts, err := Since(db, threeDaysAgo)
if err != nil {
t.Fatalf("Failed to get posts since timestamp: %v", err)
}
// Should get posts with last_post within last 3 days (includes replies)
expectedCount := 5 // Thread 1 (1 day ago), Reply 2 to Thread 1, Thread 2 (2 days ago), Reply to Thread 2, Thread 3 (2 hours ago)
if len(recentPosts) != expectedCount {
t.Errorf("Expected %d posts with activity since 3 days ago, got %d", expectedCount, len(recentPosts))
}
// Verify all posts have last_post since the timestamp
for _, post := range recentPosts {
if post.LastPost < threeDaysAgo {
t.Errorf("Post with last_post %d is before the 'since' timestamp %d", post.LastPost, threeDaysAgo)
}
}
// Test with future timestamp (should return no posts)
futurePosts, err := Since(db, time.Now().Add(time.Hour).Unix())
if err != nil {
t.Fatalf("Failed to query future timestamp: %v", err)
}
if len(futurePosts) != 0 {
t.Errorf("Expected 0 posts since future timestamp, got %d", len(futurePosts))
}
}
func TestBuilder(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Create new thread using builder
testTime := time.Now()
post, err := NewBuilder(db).
WithAuthor(5).
WithTitle("Test Thread").
WithContent("This is a test thread created with the builder").
WithPostedTime(testTime).
WithLastPostTime(testTime).
AsThread().
Create()
if err != nil {
t.Fatalf("Failed to create forum post with builder: %v", err)
}
if post.ID == 0 {
t.Error("Expected non-zero ID after creation")
}
if post.Author != 5 {
t.Errorf("Expected author 5, got %d", post.Author)
}
if post.Title != "Test Thread" {
t.Errorf("Expected title 'Test Thread', got '%s'", post.Title)
}
if post.Content != "This is a test thread created with the builder" {
t.Errorf("Expected specific content, got '%s'", post.Content)
}
if post.Posted != testTime.Unix() {
t.Errorf("Expected posted time %d, got %d", testTime.Unix(), post.Posted)
}
if post.Parent != 0 {
t.Errorf("Expected parent 0 (thread), got %d", post.Parent)
}
if post.Replies != 0 {
t.Errorf("Expected replies 0, got %d", post.Replies)
}
// Create reply using builder
reply, err := NewBuilder(db).
WithAuthor(6).
WithTitle("Re: Test Thread").
WithContent("This is a reply to the test thread").
AsReply(post.ID).
Create()
if err != nil {
t.Fatalf("Failed to create reply with builder: %v", err)
}
if reply.Parent != post.ID {
t.Errorf("Expected parent %d, got %d", post.ID, reply.Parent)
}
if !reply.IsReply() {
t.Error("Expected reply to be identified as reply")
}
// Verify posts were saved to database
foundPost, err := Find(db, post.ID)
if err != nil {
t.Fatalf("Failed to find created post: %v", err)
}
if foundPost.Title != "Test Thread" {
t.Errorf("Created post not found in database")
}
// Test builder with default timestamp
defaultPost, err := NewBuilder(db).
WithAuthor(7).
WithTitle("Default Time Post").
WithContent("Post with default timestamps").
Create()
if err != nil {
t.Fatalf("Failed to create post with default timestamp: %v", err)
}
// Should have recent timestamps (within last minute)
if time.Since(defaultPost.PostedTime()) > time.Minute {
t.Error("Expected default posted timestamp to be recent")
}
if time.Since(defaultPost.LastPostTime()) > time.Minute {
t.Error("Expected default last_post timestamp to be recent")
}
}
func TestSave(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
post, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find forum post: %v", err)
}
// Modify post
post.Title = "Updated Welcome Thread"
post.Content = "This content has been updated by moderator"
post.Replies = 3
post.UpdateLastPost()
// Save changes
err = post.Save()
if err != nil {
t.Fatalf("Failed to save forum post: %v", err)
}
// Verify changes were saved
updatedPost, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find updated post: %v", err)
}
if updatedPost.Title != "Updated Welcome Thread" {
t.Errorf("Expected updated title 'Updated Welcome Thread', got '%s'", updatedPost.Title)
}
if updatedPost.Content != "This content has been updated by moderator" {
t.Errorf("Expected updated content, got '%s'", updatedPost.Content)
}
if updatedPost.Replies != 3 {
t.Errorf("Expected updated replies 3, got %d", updatedPost.Replies)
}
}
func TestDelete(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
post, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find forum post: %v", err)
}
// Delete post
err = post.Delete()
if err != nil {
t.Fatalf("Failed to delete forum post: %v", err)
}
// Verify post was deleted
_, err = Find(db, 1)
if err == nil {
t.Error("Expected error when finding deleted post")
}
}
func TestUtilityMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
post, _ := Find(db, 1)
// Test time methods
postedTime := post.PostedTime()
if postedTime.IsZero() {
t.Error("Expected non-zero posted time")
}
lastPostTime := post.LastPostTime()
if lastPostTime.IsZero() {
t.Error("Expected non-zero last post time")
}
// Test SetPostedTime and SetLastPostTime
newTime := time.Now().Add(-2 * time.Hour)
post.SetPostedTime(newTime)
post.SetLastPostTime(newTime)
if post.Posted != newTime.Unix() {
t.Errorf("Expected posted timestamp %d, got %d", newTime.Unix(), post.Posted)
}
if post.LastPost != newTime.Unix() {
t.Errorf("Expected last_post timestamp %d, got %d", newTime.Unix(), post.LastPost)
}
// Test activity age methods
activityAge := post.ActivityAge()
if activityAge < 0 {
t.Error("Expected positive activity age")
}
postAge := post.PostAge()
if postAge < 0 {
t.Error("Expected positive post age")
}
// Test IsRecentActivity
post.UpdateLastPost() // Set to now
if !post.IsRecentActivity() {
t.Error("Expected post with current timestamp to have recent activity")
}
// Test IsAuthor
if !post.IsAuthor(post.Author) {
t.Error("Expected IsAuthor to return true for correct author")
}
if post.IsAuthor(999) {
t.Error("Expected IsAuthor to return false for incorrect author")
}
// Test HasReplies
if !post.HasReplies() {
t.Error("Expected post with replies > 0 to HasReplies")
}
// Test Preview
longContent := "This is a very long forum post content that should be truncated when preview is called for display purposes"
post.Content = longContent
preview := post.Preview(20)
if len(preview) > 20 {
t.Errorf("Expected preview length <= 20, got %d", len(preview))
}
if preview[len(preview)-3:] != "..." {
t.Error("Expected preview to end with ellipsis")
}
shortPreview := post.Preview(200) // Longer than content
if shortPreview != longContent {
t.Error("Expected short content to not be truncated")
}
// Test WordCount
post.Content = "This is a test with five words"
wordCount := post.WordCount()
if wordCount != 7 {
t.Errorf("Expected 7 words, got %d", wordCount)
}
// Test Length
expectedLength := len(post.Content)
if post.Length() != expectedLength {
t.Errorf("Expected length %d, got %d", expectedLength, post.Length())
}
// Test Contains
if !post.Contains("test") {
t.Error("Expected post to contain 'test'")
}
if !post.Contains("TEST") { // Case insensitive
t.Error("Expected Contains to be case insensitive")
}
if post.Contains("nonexistent") {
t.Error("Expected post not to contain 'nonexistent'")
}
// Test reply count methods
originalReplies := post.Replies
post.IncrementReplies()
if post.Replies != originalReplies+1 {
t.Errorf("Expected replies to be incremented to %d, got %d", originalReplies+1, post.Replies)
}
post.DecrementReplies()
if post.Replies != originalReplies {
t.Errorf("Expected replies to be decremented back to %d, got %d", originalReplies, post.Replies)
}
// Test DecrementReplies with 0 replies
post.Replies = 0
post.DecrementReplies()
if post.Replies != 0 {
t.Errorf("Expected replies to stay at 0 when decrementing from 0, got %d", post.Replies)
}
}
func TestRelationshipMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test GetReplies on a thread
thread, _ := Find(db, 1) // Thread with 2 replies
replies, err := thread.GetReplies()
if err != nil {
t.Fatalf("Failed to get replies: %v", err)
}
if len(replies) != 2 {
t.Errorf("Expected 2 replies, got %d", len(replies))
}
// Test GetThread on a reply
reply, _ := Find(db, 2) // Reply to thread 1
parentThread, err := reply.GetThread()
if err != nil {
t.Fatalf("Failed to get parent thread: %v", err)
}
if parentThread.ID != 1 {
t.Errorf("Expected parent thread ID 1, got %d", parentThread.ID)
}
// Test GetThread on a thread (should return self)
threadSelf, err := thread.GetThread()
if err != nil {
t.Fatalf("Failed to get thread (self): %v", err)
}
if threadSelf.ID != thread.ID {
t.Errorf("Expected GetThread on thread to return self, got ID %d", threadSelf.ID)
}
}

View File

@ -78,8 +78,7 @@ func createTables(db *database.DB) error {
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
level INTEGER NOT NULL DEFAULT 0, level INTEGER NOT NULL DEFAULT 0,
type INTEGER NOT NULL DEFAULT 0, type INTEGER NOT NULL DEFAULT 0,
att1 TEXT NOT NULL DEFAULT '', att TEXT NOT NULL DEFAULT ''
att2 TEXT NOT NULL DEFAULT ''
)`}, )`},
{"forum", `CREATE TABLE forum ( {"forum", `CREATE TABLE forum (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -204,38 +203,38 @@ func populateData(db *database.DB) error {
fmt.Println("✓ control table populated") fmt.Println("✓ control table populated")
dropsSQL := `INSERT INTO drops VALUES dropsSQL := `INSERT INTO drops VALUES
(1, 'Life Pebble', 1, 1, 'maxhp,10', ''), (1, 'Life Pebble', 1, 1, 'maxhp,10'),
(2, 'Life Stone', 10, 1, 'maxhp,25', ''), (2, 'Life Stone', 10, 1, 'maxhp,25'),
(3, 'Life Rock', 25, 1, 'maxhp,50', ''), (3, 'Life Rock', 25, 1, 'maxhp,50'),
(4, 'Magic Pebble', 1, 1, 'maxmp,10', ''), (4, 'Magic Pebble', 1, 1, 'maxmp,10'),
(5, 'Magic Stone', 10, 1, 'maxmp,25', ''), (5, 'Magic Stone', 10, 1, 'maxmp,25'),
(6, 'Magic Rock', 25, 1, 'maxmp,50', ''), (6, 'Magic Rock', 25, 1, 'maxmp,50'),
(7, 'Dragon''s Scale', 10, 1, 'defensepower,25', ''), (7, 'Dragon''s Scale', 10, 1, 'defensepower,25'),
(8, 'Dragon''s Plate', 30, 1, 'defensepower,50', ''), (8, 'Dragon''s Plate', 30, 1, 'defensepower,50'),
(9, 'Dragon''s Claw', 10, 1, 'attackpower,25', ''), (9, 'Dragon''s Claw', 10, 1, 'attackpower,25'),
(10, 'Dragon''s Tooth', 30, 1, 'attackpower,50', ''), (10, 'Dragon''s Tooth', 30, 1, 'attackpower,50'),
(11, 'Dragon''s Tear', 35, 1, 'strength,50', ''), (11, 'Dragon''s Tear', 35, 1, 'strength,50'),
(12, 'Dragon''s Wing', 35, 1, 'dexterity,50', ''), (12, 'Dragon''s Wing', 35, 1, 'dexterity,50'),
(13, 'Demon''s Sin', 35, 1, 'maxhp,-50', 'strength,50'), (13, 'Demon''s Sin', 35, 1, 'maxhp,-50,strength,50'),
(14, 'Demon''s Fall', 35, 1, 'maxmp,-50', 'strength,50'), (14, 'Demon''s Fall', 35, 1, 'maxmp,-50,strength,50'),
(15, 'Demon''s Lie', 45, 1, 'maxhp,-100', 'strength,100'), (15, 'Demon''s Lie', 45, 1, 'maxhp,-100,strength,100'),
(16, 'Demon''s Hate', 45, 1, 'maxmp,-100', 'strength,100'), (16, 'Demon''s Hate', 45, 1, 'maxmp,-100,strength,100'),
(17, 'Angel''s Joy', 25, 1, 'maxhp,25', 'strength,25'), (17, 'Angel''s Joy', 25, 1, 'maxhp,25,strength,25'),
(18, 'Angel''s Rise', 30, 1, 'maxhp,50', 'strength,50'), (18, 'Angel''s Rise', 30, 1, 'maxhp,50,strength,50'),
(19, 'Angel''s Truth', 35, 1, 'maxhp,75', 'strength,75'), (19, 'Angel''s Truth', 35, 1, 'maxhp,75,strength,75'),
(20, 'Angel''s Love', 40, 1, 'maxhp,100', 'strength,100'), (20, 'Angel''s Love', 40, 1, 'maxhp,100,strength,100'),
(21, 'Seraph''s Joy', 25, 1, 'maxmp,25', 'dexterity,25'), (21, 'Seraph''s Joy', 25, 1, 'maxmp,25,dexterity,25'),
(22, 'Seraph''s Rise', 30, 1, 'maxmp,50', 'dexterity,50'), (22, 'Seraph''s Rise', 30, 1, 'maxmp,50,dexterity,50'),
(23, 'Seraph''s Truth', 35, 1, 'maxmp,75', 'dexterity,75'), (23, 'Seraph''s Truth', 35, 1, 'maxmp,75,dexterity,75'),
(24, 'Seraph''s Love', 40, 1, 'maxmp,100', 'dexterity,100'), (24, 'Seraph''s Love', 40, 1, 'maxmp,100,dexterity,100'),
(25, 'Ruby', 50, 1, 'maxhp,150', ''), (25, 'Ruby', 50, 1, 'maxhp,150'),
(26, 'Pearl', 50, 1, 'maxmp,150', ''), (26, 'Pearl', 50, 1, 'maxmp,150'),
(27, 'Emerald', 50, 1, 'strength,150', ''), (27, 'Emerald', 50, 1, 'strength,150'),
(28, 'Topaz', 50, 1, 'dexterity,150', ''), (28, 'Topaz', 50, 1, 'dexterity,150'),
(29, 'Obsidian', 50, 1, 'attackpower,150', ''), (29, 'Obsidian', 50, 1, 'attackpower,150'),
(30, 'Diamond', 50, 1, 'defensepower,150', ''), (30, 'Diamond', 50, 1, 'defensepower,150'),
(31, 'Memory Drop', 5, 1, 'expbonus,10', ''), (31, 'Memory Drop', 5, 1, 'expbonus,10'),
(32, 'Fortune Drop', 5, 1, 'goldbonus,10', '')` (32, 'Fortune Drop', 5, 1, 'goldbonus,10')`
if err := db.Exec(dropsSQL); err != nil { if err := db.Exec(dropsSQL); err != nil {
return fmt.Errorf("failed to populate drops table: %w", err) return fmt.Errorf("failed to populate drops table: %w", err)
} }

95
internal/items/builder.go Normal file
View File

@ -0,0 +1,95 @@
package items
import (
"dk/internal/database"
"fmt"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating items
type Builder struct {
item *Item
db *database.DB
}
// NewBuilder creates a new item builder
func NewBuilder(db *database.DB) *Builder {
return &Builder{
item: &Item{db: db},
db: db,
}
}
// 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 := b.db.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,
db: b.db,
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to create item: %w", err)
}
return item, nil
}

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"dk/internal/database" "dk/internal/database"
"zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite"
) )
@ -15,7 +16,7 @@ type Item struct {
Value int `json:"value"` Value int `json:"value"`
Att int `json:"att"` Att int `json:"att"`
Special string `json:"special"` Special string `json:"special"`
db *database.DB db *database.DB
} }
@ -29,7 +30,7 @@ const (
// Find retrieves an item by ID // Find retrieves an item by ID
func Find(db *database.DB, id int) (*Item, error) { func Find(db *database.DB, id int) (*Item, error) {
item := &Item{db: db} item := &Item{db: db}
query := "SELECT id, type, name, value, att, special FROM items WHERE id = ?" query := "SELECT id, type, name, value, att, special FROM items WHERE id = ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error { err := db.Query(query, func(stmt *sqlite.Stmt) error {
item.ID = stmt.ColumnInt(0) item.ID = stmt.ColumnInt(0)
@ -40,22 +41,22 @@ func Find(db *database.DB, id int) (*Item, error) {
item.Special = stmt.ColumnText(5) item.Special = stmt.ColumnText(5)
return nil return nil
}, id) }, id)
if err != nil { if err != nil {
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.ID == 0 {
return nil, fmt.Errorf("item with ID %d not found", id) return nil, fmt.Errorf("item with ID %d not found", id)
} }
return item, nil return item, nil
} }
// All retrieves all items // All retrieves all items
func All(db *database.DB) ([]*Item, error) { func All(db *database.DB) ([]*Item, error) {
var items []*Item var items []*Item
query := "SELECT id, type, name, value, att, special FROM items ORDER BY id" query := "SELECT id, type, name, value, att, special FROM items ORDER BY id"
err := db.Query(query, func(stmt *sqlite.Stmt) error { err := db.Query(query, func(stmt *sqlite.Stmt) error {
item := &Item{ item := &Item{
@ -70,18 +71,18 @@ func All(db *database.DB) ([]*Item, error) {
items = append(items, item) items = append(items, item)
return nil return nil
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to retrieve all items: %w", err) return nil, fmt.Errorf("failed to retrieve all items: %w", err)
} }
return items, nil return items, nil
} }
// ByType retrieves items by type // ByType retrieves items by type
func ByType(db *database.DB, itemType int) ([]*Item, error) { func ByType(db *database.DB, 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 id, type, name, value, att, special FROM items WHERE type = ? ORDER BY id"
err := db.Query(query, func(stmt *sqlite.Stmt) error { err := db.Query(query, func(stmt *sqlite.Stmt) error {
item := &Item{ item := &Item{
@ -96,107 +97,20 @@ func ByType(db *database.DB, itemType int) ([]*Item, error) {
items = append(items, item) items = append(items, item)
return nil return nil
}, itemType) }, itemType)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to retrieve items by type: %w", err) return nil, fmt.Errorf("failed to retrieve items by type: %w", err)
} }
return items, nil return items, nil
} }
// Builder provides a fluent interface for creating items
type Builder struct {
item *Item
db *database.DB
}
// NewBuilder creates a new item builder
func NewBuilder(db *database.DB) *Builder {
return &Builder{
item: &Item{db: db},
db: db,
}
}
// 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 := b.db.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,
db: b.db,
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to create item: %w", err)
}
return item, nil
}
// Save updates an existing item in the database // Save updates an existing item in the database
func (i *Item) Save() error { func (i *Item) Save() error {
if i.ID == 0 { if i.ID == 0 {
return fmt.Errorf("cannot save item without ID") return fmt.Errorf("cannot save item without ID")
} }
query := `UPDATE items SET type = ?, name = ?, value = ?, att = ?, special = ? WHERE id = ?` query := `UPDATE items SET type = ?, name = ?, value = ?, att = ?, special = ? WHERE id = ?`
return i.db.Exec(query, i.Type, i.Name, i.Value, i.Att, i.Special, i.ID) return i.db.Exec(query, i.Type, i.Name, i.Value, i.Att, i.Special, i.ID)
} }
@ -206,7 +120,7 @@ func (i *Item) Delete() error {
if i.ID == 0 { if i.ID == 0 {
return fmt.Errorf("cannot delete item without ID") return fmt.Errorf("cannot delete item without ID")
} }
query := "DELETE FROM items WHERE id = ?" query := "DELETE FROM items WHERE id = ?"
return i.db.Exec(query, i.ID) return i.db.Exec(query, i.ID)
} }
@ -238,4 +152,4 @@ func (i *Item) TypeName() string {
default: default:
return "Unknown" return "Unknown"
} }
} }

View File

@ -0,0 +1,117 @@
package monsters
import (
"dk/internal/database"
"fmt"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating monsters
type Builder struct {
monster *Monster
db *database.DB
}
// NewBuilder creates a new monster builder
func NewBuilder(db *database.DB) *Builder {
return &Builder{
monster: &Monster{db: db},
db: db,
}
}
// 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 := b.db.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,
db: b.db,
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to create monster: %w", err)
}
return monster, nil
}

207
internal/monsters/doc.go Normal file
View File

@ -0,0 +1,207 @@
/*
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

@ -0,0 +1,251 @@
package monsters
import (
"fmt"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Monster represents a monster in the database
type Monster struct {
ID int `json:"id"`
Name string `json:"name"`
MaxHP int `json:"max_hp"`
MaxDmg int `json:"max_dmg"`
Armor int `json:"armor"`
Level int `json:"level"`
MaxExp int `json:"max_exp"`
MaxGold int `json:"max_gold"`
Immune int `json:"immune"`
db *database.DB
}
// Immunity constants for monster immunity types
const (
ImmuneNone = 0
ImmuneHurt = 1 // Immune to Hurt spells
ImmuneSleep = 2 // Immune to Sleep spells
)
// Find retrieves a monster by ID
func Find(db *database.DB, id int) (*Monster, error) {
monster := &Monster{db: db}
query := "SELECT id, name, max_hp, max_dmg, armor, level, max_exp, max_gold, immune FROM monsters WHERE id = ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
monster.ID = stmt.ColumnInt(0)
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
}, id)
if err != nil {
return nil, fmt.Errorf("failed to find monster: %w", err)
}
if monster.ID == 0 {
return nil, fmt.Errorf("monster with ID %d not found", id)
}
return monster, nil
}
// All retrieves all monsters
func All(db *database.DB) ([]*Monster, error) {
var monsters []*Monster
query := "SELECT id, name, max_hp, max_dmg, armor, level, max_exp, max_gold, immune FROM monsters ORDER BY level, id"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
monster := &Monster{
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),
db: db,
}
monsters = append(monsters, monster)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to retrieve all monsters: %w", err)
}
return monsters, nil
}
// ByLevel retrieves monsters by level
func ByLevel(db *database.DB, level int) ([]*Monster, error) {
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"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
monster := &Monster{
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),
db: db,
}
monsters = append(monsters, monster)
return nil
}, level)
if err != nil {
return nil, fmt.Errorf("failed to retrieve monsters by level: %w", err)
}
return monsters, nil
}
// ByLevelRange retrieves monsters within a level range (inclusive)
func ByLevelRange(db *database.DB, minLevel, maxLevel int) ([]*Monster, error) {
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"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
monster := &Monster{
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),
db: db,
}
monsters = append(monsters, monster)
return nil
}, minLevel, maxLevel)
if err != nil {
return nil, fmt.Errorf("failed to retrieve monsters by level range: %w", err)
}
return monsters, nil
}
// ByImmunity retrieves monsters by immunity type
func ByImmunity(db *database.DB, immunityType int) ([]*Monster, error) {
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"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
monster := &Monster{
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),
db: db,
}
monsters = append(monsters, monster)
return nil
}, immunityType)
if err != nil {
return nil, fmt.Errorf("failed to retrieve monsters by immunity: %w", err)
}
return monsters, nil
}
// Save updates an existing monster in the database
func (m *Monster) Save() error {
if m.ID == 0 {
return fmt.Errorf("cannot save monster without ID")
}
query := `UPDATE monsters SET name = ?, max_hp = ?, max_dmg = ?, armor = ?, level = ?, max_exp = ?, max_gold = ?, immune = ? WHERE id = ?`
return m.db.Exec(query, m.Name, m.MaxHP, m.MaxDmg, m.Armor, m.Level, m.MaxExp, m.MaxGold, m.Immune, m.ID)
}
// Delete removes the monster from the database
func (m *Monster) Delete() error {
if m.ID == 0 {
return fmt.Errorf("cannot delete monster without ID")
}
query := "DELETE FROM monsters WHERE id = ?"
return m.db.Exec(query, m.ID)
}
// IsHurtImmune returns true if the monster is immune to Hurt spells
func (m *Monster) IsHurtImmune() bool {
return m.Immune == ImmuneHurt
}
// IsSleepImmune returns true if the monster is immune to Sleep spells
func (m *Monster) IsSleepImmune() bool {
return m.Immune == ImmuneSleep
}
// HasImmunity returns true if the monster has any immunity
func (m *Monster) HasImmunity() bool {
return m.Immune != ImmuneNone
}
// ImmunityName returns the string representation of the monster's immunity
func (m *Monster) ImmunityName() string {
switch m.Immune {
case ImmuneNone:
return "None"
case ImmuneHurt:
return "Hurt Spells"
case ImmuneSleep:
return "Sleep Spells"
default:
return "Unknown"
}
}
// DifficultyRating calculates a simple difficulty rating based on stats
func (m *Monster) DifficultyRating() float64 {
// Simple formula: (HP + Damage + Armor) / Level
// Higher values indicate tougher monsters relative to their level
if m.Level == 0 {
return 0
}
return float64(m.MaxHP+m.MaxDmg+m.Armor) / float64(m.Level)
}
// ExpPerHP returns the experience reward per hit point (efficiency metric)
func (m *Monster) ExpPerHP() float64 {
if m.MaxHP == 0 {
return 0
}
return float64(m.MaxExp) / float64(m.MaxHP)
}
// GoldPerHP returns the gold reward per hit point (efficiency metric)
func (m *Monster) GoldPerHP() float64 {
if m.MaxHP == 0 {
return 0
}
return float64(m.MaxGold) / float64(m.MaxHP)
}

View File

@ -0,0 +1,408 @@
package monsters
import (
"os"
"testing"
"dk/internal/database"
)
func setupTestDB(t *testing.T) *database.DB {
testDB := "test_monsters.db"
t.Cleanup(func() {
os.Remove(testDB)
})
db, err := database.Open(testDB)
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
// Create monsters table
createTable := `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
)`
if err := db.Exec(createTable); err != nil {
t.Fatalf("Failed to create monsters table: %v", err)
}
// Insert test data
testMonsters := `INSERT INTO monsters (name, max_hp, max_dmg, armor, level, max_exp, max_gold, immune) VALUES
('Blue Slime', 4, 3, 1, 1, 1, 1, 0),
('Red Slime', 6, 5, 1, 1, 2, 1, 0),
('Shadow', 10, 9, 3, 2, 6, 2, 1),
('Silver Slime', 15, 100, 200, 30, 15, 1000, 2),
('Raven', 16, 13, 5, 4, 18, 6, 0)`
if err := db.Exec(testMonsters); err != nil {
t.Fatalf("Failed to insert test monsters: %v", err)
}
return db
}
func TestFind(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test finding existing monster
monster, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find monster: %v", err)
}
if monster.ID != 1 {
t.Errorf("Expected ID 1, got %d", monster.ID)
}
if monster.Name != "Blue Slime" {
t.Errorf("Expected name 'Blue Slime', got '%s'", monster.Name)
}
if monster.MaxHP != 4 {
t.Errorf("Expected max_hp 4, got %d", monster.MaxHP)
}
if monster.MaxDmg != 3 {
t.Errorf("Expected max_dmg 3, got %d", monster.MaxDmg)
}
if monster.Armor != 1 {
t.Errorf("Expected armor 1, got %d", monster.Armor)
}
if monster.Level != 1 {
t.Errorf("Expected level 1, got %d", monster.Level)
}
if monster.MaxExp != 1 {
t.Errorf("Expected max_exp 1, got %d", monster.MaxExp)
}
if monster.MaxGold != 1 {
t.Errorf("Expected max_gold 1, got %d", monster.MaxGold)
}
if monster.Immune != ImmuneNone {
t.Errorf("Expected immune %d, got %d", ImmuneNone, monster.Immune)
}
// Test finding non-existent monster
_, err = Find(db, 999)
if err == nil {
t.Error("Expected error when finding non-existent monster")
}
}
func TestAll(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
monsters, err := All(db)
if err != nil {
t.Fatalf("Failed to get all monsters: %v", err)
}
if len(monsters) != 5 {
t.Errorf("Expected 5 monsters, got %d", len(monsters))
}
// Check first monster (should be ordered by level, then id)
if monsters[0].Name != "Blue Slime" {
t.Errorf("Expected first monster to be 'Blue Slime', got '%s'", monsters[0].Name)
}
}
func TestByLevel(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test level 1 monsters
level1Monsters, err := ByLevel(db, 1)
if err != nil {
t.Fatalf("Failed to get level 1 monsters: %v", err)
}
if len(level1Monsters) != 2 {
t.Errorf("Expected 2 level 1 monsters, got %d", len(level1Monsters))
}
for _, monster := range level1Monsters {
if monster.Level != 1 {
t.Errorf("Expected level 1, got %d for monster %s", monster.Level, monster.Name)
}
}
// Test level that doesn't exist
noMonsters, err := ByLevel(db, 999)
if err != nil {
t.Fatalf("Failed to query non-existent level: %v", err)
}
if len(noMonsters) != 0 {
t.Errorf("Expected 0 monsters at level 999, got %d", len(noMonsters))
}
}
func TestByLevelRange(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test level range 1-2
monsters, err := ByLevelRange(db, 1, 2)
if err != nil {
t.Fatalf("Failed to get monsters by level range: %v", err)
}
if len(monsters) != 3 {
t.Errorf("Expected 3 monsters in level range 1-2, got %d", len(monsters))
}
// Verify all monsters are within range
for _, monster := range monsters {
if monster.Level < 1 || monster.Level > 2 {
t.Errorf("Monster %s level %d is outside range 1-2", monster.Name, monster.Level)
}
}
// Verify ordering (by level, then id)
if monsters[0].Level > monsters[len(monsters)-1].Level {
t.Error("Expected monsters to be ordered by level")
}
}
func TestByImmunity(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test Hurt immune monsters
hurtImmune, err := ByImmunity(db, ImmuneHurt)
if err != nil {
t.Fatalf("Failed to get Hurt immune monsters: %v", err)
}
if len(hurtImmune) != 1 {
t.Errorf("Expected 1 Hurt immune monster, got %d", len(hurtImmune))
}
if len(hurtImmune) > 0 && hurtImmune[0].Name != "Shadow" {
t.Errorf("Expected Hurt immune monster to be 'Shadow', got '%s'", hurtImmune[0].Name)
}
// Test Sleep immune monsters
sleepImmune, err := ByImmunity(db, ImmuneSleep)
if err != nil {
t.Fatalf("Failed to get Sleep immune monsters: %v", err)
}
if len(sleepImmune) != 1 {
t.Errorf("Expected 1 Sleep immune monster, got %d", len(sleepImmune))
}
// Test no immunity monsters
noImmunity, err := ByImmunity(db, ImmuneNone)
if err != nil {
t.Fatalf("Failed to get non-immune monsters: %v", err)
}
if len(noImmunity) != 3 {
t.Errorf("Expected 3 non-immune monsters, got %d", len(noImmunity))
}
}
func TestBuilder(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Create new monster using builder
monster, err := NewBuilder(db).
WithName("Test Dragon").
WithMaxHP(100).
WithMaxDmg(25).
WithArmor(10).
WithLevel(15).
WithMaxExp(500).
WithMaxGold(100).
WithImmunity(ImmuneHurt).
Create()
if err != nil {
t.Fatalf("Failed to create monster with builder: %v", err)
}
if monster.ID == 0 {
t.Error("Expected non-zero ID after creation")
}
if monster.Name != "Test Dragon" {
t.Errorf("Expected name 'Test Dragon', got '%s'", monster.Name)
}
if monster.MaxHP != 100 {
t.Errorf("Expected max_hp 100, got %d", monster.MaxHP)
}
if monster.MaxDmg != 25 {
t.Errorf("Expected max_dmg 25, got %d", monster.MaxDmg)
}
if monster.Armor != 10 {
t.Errorf("Expected armor 10, got %d", monster.Armor)
}
if monster.Level != 15 {
t.Errorf("Expected level 15, got %d", monster.Level)
}
if monster.MaxExp != 500 {
t.Errorf("Expected max_exp 500, got %d", monster.MaxExp)
}
if monster.MaxGold != 100 {
t.Errorf("Expected max_gold 100, got %d", monster.MaxGold)
}
if monster.Immune != ImmuneHurt {
t.Errorf("Expected immune %d, got %d", ImmuneHurt, monster.Immune)
}
// Verify it was saved to database
foundMonster, err := Find(db, monster.ID)
if err != nil {
t.Fatalf("Failed to find created monster: %v", err)
}
if foundMonster.Name != "Test Dragon" {
t.Errorf("Created monster not found in database")
}
}
func TestSave(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
monster, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find monster: %v", err)
}
// Modify monster
monster.Name = "Updated Blue Slime"
monster.MaxHP = 8
monster.Level = 2
// Save changes
err = monster.Save()
if err != nil {
t.Fatalf("Failed to save monster: %v", err)
}
// Verify changes were saved
updatedMonster, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find updated monster: %v", err)
}
if updatedMonster.Name != "Updated Blue Slime" {
t.Errorf("Expected updated name 'Updated Blue Slime', got '%s'", updatedMonster.Name)
}
if updatedMonster.MaxHP != 8 {
t.Errorf("Expected updated max_hp 8, got %d", updatedMonster.MaxHP)
}
if updatedMonster.Level != 2 {
t.Errorf("Expected updated level 2, got %d", updatedMonster.Level)
}
}
func TestDelete(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
monster, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find monster: %v", err)
}
// Delete monster
err = monster.Delete()
if err != nil {
t.Fatalf("Failed to delete monster: %v", err)
}
// Verify monster was deleted
_, err = Find(db, 1)
if err == nil {
t.Error("Expected error when finding deleted monster")
}
}
func TestImmunityMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
blueSlime, _ := Find(db, 1) // No immunity
shadow, _ := Find(db, 3) // Hurt immune
silverSlime, _ := Find(db, 4) // Sleep immune
// Test IsHurtImmune
if blueSlime.IsHurtImmune() {
t.Error("Expected blue slime not to be Hurt immune")
}
if !shadow.IsHurtImmune() {
t.Error("Expected shadow to be Hurt immune")
}
if silverSlime.IsHurtImmune() {
t.Error("Expected silver slime not to be Hurt immune")
}
// Test IsSleepImmune
if blueSlime.IsSleepImmune() {
t.Error("Expected blue slime not to be Sleep immune")
}
if shadow.IsSleepImmune() {
t.Error("Expected shadow not to be Sleep immune")
}
if !silverSlime.IsSleepImmune() {
t.Error("Expected silver slime to be Sleep immune")
}
// Test HasImmunity
if blueSlime.HasImmunity() {
t.Error("Expected blue slime to have no immunity")
}
if !shadow.HasImmunity() {
t.Error("Expected shadow to have immunity")
}
if !silverSlime.HasImmunity() {
t.Error("Expected silver slime to have immunity")
}
// Test ImmunityName
if blueSlime.ImmunityName() != "None" {
t.Errorf("Expected blue slime immunity name 'None', got '%s'", blueSlime.ImmunityName())
}
if shadow.ImmunityName() != "Hurt Spells" {
t.Errorf("Expected shadow immunity name 'Hurt Spells', got '%s'", shadow.ImmunityName())
}
if silverSlime.ImmunityName() != "Sleep Spells" {
t.Errorf("Expected silver slime immunity name 'Sleep Spells', got '%s'", silverSlime.ImmunityName())
}
}
func TestUtilityMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
blueSlime, _ := Find(db, 1)
// Test DifficultyRating
expectedDifficulty := float64(4+3+1) / float64(1) // (HP + Damage + Armor) / Level
if blueSlime.DifficultyRating() != expectedDifficulty {
t.Errorf("Expected difficulty rating %.2f, got %.2f", expectedDifficulty, blueSlime.DifficultyRating())
}
// Test ExpPerHP
expectedExpPerHP := float64(1) / float64(4) // Exp / HP
if blueSlime.ExpPerHP() != expectedExpPerHP {
t.Errorf("Expected exp per HP %.2f, got %.2f", expectedExpPerHP, blueSlime.ExpPerHP())
}
// Test GoldPerHP
expectedGoldPerHP := float64(1) / float64(4) // Gold / HP
if blueSlime.GoldPerHP() != expectedGoldPerHP {
t.Errorf("Expected gold per HP %.2f, got %.2f", expectedGoldPerHP, blueSlime.GoldPerHP())
}
}

85
internal/news/builder.go Normal file
View File

@ -0,0 +1,85 @@
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
db *database.DB
}
// NewBuilder creates a new news builder
func NewBuilder(db *database.DB) *Builder {
return &Builder{
news: &News{
db: db,
Posted: time.Now().Unix(), // Default to current time
},
db: db,
}
}
// 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 := b.db.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
}

349
internal/news/doc.go Normal file
View File

@ -0,0 +1,349 @@
/*
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

250
internal/news/news.go Normal file
View File

@ -0,0 +1,250 @@
package news
import (
"fmt"
"time"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// News represents a news post in the database
type News struct {
ID int `json:"id"`
Author int `json:"author"`
Posted int64 `json:"posted"`
Content string `json:"content"`
db *database.DB
}
// Find retrieves a news post by ID
func Find(db *database.DB, id int) (*News, error) {
news := &News{db: db}
query := "SELECT id, author, posted, content FROM news WHERE id = ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
news.ID = stmt.ColumnInt(0)
news.Author = stmt.ColumnInt(1)
news.Posted = stmt.ColumnInt64(2)
news.Content = stmt.ColumnText(3)
return nil
}, id)
if err != nil {
return nil, fmt.Errorf("failed to find news: %w", err)
}
if news.ID == 0 {
return nil, fmt.Errorf("news with ID %d not found", id)
}
return news, nil
}
// All retrieves all news posts ordered by posted date (newest first)
func All(db *database.DB) ([]*News, error) {
var newsPosts []*News
query := "SELECT id, author, posted, content FROM news ORDER BY posted DESC, id DESC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
news := &News{
ID: stmt.ColumnInt(0),
Author: stmt.ColumnInt(1),
Posted: stmt.ColumnInt64(2),
Content: stmt.ColumnText(3),
db: db,
}
newsPosts = append(newsPosts, news)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to retrieve all news: %w", err)
}
return newsPosts, nil
}
// ByAuthor retrieves news posts by a specific author
func ByAuthor(db *database.DB, authorID int) ([]*News, error) {
var newsPosts []*News
query := "SELECT id, author, posted, content FROM news WHERE author = ? ORDER BY posted DESC, id DESC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
news := &News{
ID: stmt.ColumnInt(0),
Author: stmt.ColumnInt(1),
Posted: stmt.ColumnInt64(2),
Content: stmt.ColumnText(3),
db: db,
}
newsPosts = append(newsPosts, news)
return nil
}, authorID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve news by author: %w", err)
}
return newsPosts, nil
}
// Recent retrieves the most recent news posts (limited by count)
func Recent(db *database.DB, limit int) ([]*News, error) {
var newsPosts []*News
query := "SELECT id, author, posted, content FROM news ORDER BY posted DESC, id DESC LIMIT ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
news := &News{
ID: stmt.ColumnInt(0),
Author: stmt.ColumnInt(1),
Posted: stmt.ColumnInt64(2),
Content: stmt.ColumnText(3),
db: db,
}
newsPosts = append(newsPosts, news)
return nil
}, limit)
if err != nil {
return nil, fmt.Errorf("failed to retrieve recent news: %w", err)
}
return newsPosts, nil
}
// Since retrieves news posts since a specific timestamp
func Since(db *database.DB, since int64) ([]*News, error) {
var newsPosts []*News
query := "SELECT id, author, posted, content FROM news WHERE posted >= ? ORDER BY posted DESC, id DESC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
news := &News{
ID: stmt.ColumnInt(0),
Author: stmt.ColumnInt(1),
Posted: stmt.ColumnInt64(2),
Content: stmt.ColumnText(3),
db: db,
}
newsPosts = append(newsPosts, news)
return nil
}, since)
if err != nil {
return nil, fmt.Errorf("failed to retrieve news since timestamp: %w", err)
}
return newsPosts, nil
}
// Between retrieves news posts between two timestamps (inclusive)
func Between(db *database.DB, start, end int64) ([]*News, error) {
var newsPosts []*News
query := "SELECT id, author, posted, content FROM news WHERE posted >= ? AND posted <= ? ORDER BY posted DESC, id DESC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
news := &News{
ID: stmt.ColumnInt(0),
Author: stmt.ColumnInt(1),
Posted: stmt.ColumnInt64(2),
Content: stmt.ColumnText(3),
db: db,
}
newsPosts = append(newsPosts, news)
return nil
}, start, end)
if err != nil {
return nil, fmt.Errorf("failed to retrieve news between timestamps: %w", err)
}
return newsPosts, nil
}
// Save updates an existing news post in the database
func (n *News) Save() error {
if n.ID == 0 {
return fmt.Errorf("cannot save news without ID")
}
query := `UPDATE news SET author = ?, posted = ?, content = ? WHERE id = ?`
return n.db.Exec(query, n.Author, n.Posted, n.Content, n.ID)
}
// Delete removes the news post from the database
func (n *News) Delete() error {
if n.ID == 0 {
return fmt.Errorf("cannot delete news without ID")
}
query := "DELETE FROM news WHERE id = ?"
return n.db.Exec(query, n.ID)
}
// PostedTime returns the posted timestamp as a time.Time
func (n *News) PostedTime() time.Time {
return time.Unix(n.Posted, 0)
}
// SetPostedTime sets the posted timestamp from a time.Time
func (n *News) SetPostedTime(t time.Time) {
n.Posted = t.Unix()
}
// IsRecent returns true if the news post was made within the last 24 hours
func (n *News) IsRecent() bool {
return time.Since(n.PostedTime()) < 24*time.Hour
}
// Age returns how long ago the news post was made
func (n *News) Age() time.Duration {
return time.Since(n.PostedTime())
}
// IsAuthor returns true if the given user ID is the author of this news post
func (n *News) IsAuthor(userID int) bool {
return n.Author == userID
}
// Preview returns a truncated version of the content for previews
func (n *News) Preview(maxLength int) string {
if len(n.Content) <= maxLength {
return n.Content
}
if maxLength < 3 {
return n.Content[:maxLength]
}
return n.Content[:maxLength-3] + "..."
}
// WordCount returns the number of words in the content
func (n *News) WordCount() int {
if n.Content == "" {
return 0
}
// Simple word count by splitting on spaces
words := 0
inWord := false
for _, char := range n.Content {
if char == ' ' || char == '\t' || char == '\n' || char == '\r' {
if inWord {
words++
inWord = false
}
} else {
inWord = true
}
}
if inWord {
words++
}
return words
}

459
internal/news/news_test.go Normal file
View File

@ -0,0 +1,459 @@
package news
import (
"os"
"testing"
"time"
"dk/internal/database"
)
func setupTestDB(t *testing.T) *database.DB {
testDB := "test_news.db"
t.Cleanup(func() {
os.Remove(testDB)
})
db, err := database.Open(testDB)
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
// Create news table
createTable := `CREATE TABLE news (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author INTEGER NOT NULL,
posted INTEGER NOT NULL DEFAULT (unixepoch()),
content TEXT NOT NULL
)`
if err := db.Exec(createTable); err != nil {
t.Fatalf("Failed to create news table: %v", err)
}
// Insert test data with specific timestamps for predictable testing
now := time.Now().Unix()
testNews := `INSERT INTO news (author, posted, content) VALUES
(1, ?, 'First news post about game updates'),
(2, ?, 'Second post from different author'),
(1, ?, 'Third post - recent update'),
(3, ?, 'Fourth post from admin'),
(2, ?, 'Fifth post - maintenance notice')`
timestamps := []interface{}{
now - 86400*7, // 1 week ago
now - 86400*5, // 5 days ago
now - 86400*2, // 2 days ago
now - 86400*1, // 1 day ago
now - 3600, // 1 hour ago
}
if err := db.Exec(testNews, timestamps...); err != nil {
t.Fatalf("Failed to insert test news: %v", err)
}
return db
}
func TestFind(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test finding existing news
news, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find news: %v", err)
}
if news.ID != 1 {
t.Errorf("Expected ID 1, got %d", news.ID)
}
if news.Author != 1 {
t.Errorf("Expected author 1, got %d", news.Author)
}
if news.Content != "First news post about game updates" {
t.Errorf("Expected specific content, got '%s'", news.Content)
}
if news.Posted == 0 {
t.Error("Expected non-zero posted timestamp")
}
// Test finding non-existent news
_, err = Find(db, 999)
if err == nil {
t.Error("Expected error when finding non-existent news")
}
}
func TestAll(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
newsPosts, err := All(db)
if err != nil {
t.Fatalf("Failed to get all news: %v", err)
}
if len(newsPosts) != 5 {
t.Errorf("Expected 5 news posts, got %d", len(newsPosts))
}
// Check ordering (newest first)
if len(newsPosts) >= 2 {
if newsPosts[0].Posted < newsPosts[1].Posted {
t.Error("Expected news to be ordered by posted time (newest first)")
}
}
// First post should be the most recent (1 hour ago)
if newsPosts[0].Content != "Fifth post - maintenance notice" {
t.Errorf("Expected newest post first, got '%s'", newsPosts[0].Content)
}
}
func TestByAuthor(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test posts by author 1
author1Posts, err := ByAuthor(db, 1)
if err != nil {
t.Fatalf("Failed to get news by author: %v", err)
}
if len(author1Posts) != 2 {
t.Errorf("Expected 2 posts by author 1, got %d", len(author1Posts))
}
// Verify all posts are by author 1
for _, post := range author1Posts {
if post.Author != 1 {
t.Errorf("Expected author 1, got %d", post.Author)
}
}
// Check ordering (newest first)
if len(author1Posts) == 2 {
if author1Posts[0].Content != "Third post - recent update" {
t.Errorf("Expected newest post by author 1 first")
}
}
// Test author with no posts
noPosts, err := ByAuthor(db, 999)
if err != nil {
t.Fatalf("Failed to query non-existent author: %v", err)
}
if len(noPosts) != 0 {
t.Errorf("Expected 0 posts by non-existent author, got %d", len(noPosts))
}
}
func TestRecent(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test getting 3 most recent posts
recentPosts, err := Recent(db, 3)
if err != nil {
t.Fatalf("Failed to get recent news: %v", err)
}
if len(recentPosts) != 3 {
t.Errorf("Expected 3 recent posts, got %d", len(recentPosts))
}
// Check ordering (newest first)
if len(recentPosts) >= 2 {
if recentPosts[0].Posted < recentPosts[1].Posted {
t.Error("Expected recent posts to be ordered newest first")
}
}
// Test getting more posts than exist
allRecentPosts, err := Recent(db, 10)
if err != nil {
t.Fatalf("Failed to get recent news with high limit: %v", err)
}
if len(allRecentPosts) != 5 {
t.Errorf("Expected 5 posts (all available), got %d", len(allRecentPosts))
}
}
func TestSince(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test posts since 3 days ago
threeDaysAgo := time.Now().AddDate(0, 0, -3).Unix()
recentPosts, err := Since(db, threeDaysAgo)
if err != nil {
t.Fatalf("Failed to get news since timestamp: %v", err)
}
// Should get posts from 2 days ago, 1 day ago, and 1 hour ago
expectedCount := 3
if len(recentPosts) != expectedCount {
t.Errorf("Expected %d posts since 3 days ago, got %d", expectedCount, len(recentPosts))
}
// Verify all posts are since the timestamp
for _, post := range recentPosts {
if post.Posted < threeDaysAgo {
t.Errorf("Post with timestamp %d is before the 'since' timestamp %d", post.Posted, threeDaysAgo)
}
}
// Test with future timestamp (should return no posts)
futurePosts, err := Since(db, time.Now().AddDate(0, 0, 1).Unix())
if err != nil {
t.Fatalf("Failed to query future timestamp: %v", err)
}
if len(futurePosts) != 0 {
t.Errorf("Expected 0 posts since future timestamp, got %d", len(futurePosts))
}
}
func TestBetween(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test posts between 6 days ago and 1 day ago
start := time.Now().AddDate(0, 0, -6).Unix()
end := time.Now().AddDate(0, 0, -1).Unix()
betweenPosts, err := Between(db, start, end)
if err != nil {
t.Fatalf("Failed to get news between timestamps: %v", err)
}
// Should get posts from 5 days ago, 2 days ago, and 1 day ago
expectedCount := 3
if len(betweenPosts) != expectedCount {
t.Errorf("Expected %d posts between timestamps, got %d", expectedCount, len(betweenPosts))
}
// Verify all posts are within the range
for _, post := range betweenPosts {
if post.Posted < start || post.Posted > end {
t.Errorf("Post with timestamp %d is outside range [%d, %d]", post.Posted, start, end)
}
}
// Test with narrow range (should return fewer posts)
narrowStart := time.Now().AddDate(0, 0, -2).Unix()
narrowEnd := time.Now().AddDate(0, 0, -1).Unix()
narrowPosts, err := Between(db, narrowStart, narrowEnd)
if err != nil {
t.Fatalf("Failed to get news in narrow range: %v", err)
}
if len(narrowPosts) != 2 { // 2 days ago and 1 day ago
t.Errorf("Expected 2 posts in narrow range, got %d", len(narrowPosts))
}
}
func TestBuilder(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Create new news using builder
testTime := time.Now()
news, err := NewBuilder(db).
WithAuthor(5).
WithContent("Test news content from builder").
WithPostedTime(testTime).
Create()
if err != nil {
t.Fatalf("Failed to create news with builder: %v", err)
}
if news.ID == 0 {
t.Error("Expected non-zero ID after creation")
}
if news.Author != 5 {
t.Errorf("Expected author 5, got %d", news.Author)
}
if news.Content != "Test news content from builder" {
t.Errorf("Expected specific content, got '%s'", news.Content)
}
if news.Posted != testTime.Unix() {
t.Errorf("Expected posted time %d, got %d", testTime.Unix(), news.Posted)
}
// Verify it was saved to database
foundNews, err := Find(db, news.ID)
if err != nil {
t.Fatalf("Failed to find created news: %v", err)
}
if foundNews.Content != "Test news content from builder" {
t.Errorf("Created news not found in database")
}
// Test builder with default timestamp
defaultNews, err := NewBuilder(db).
WithAuthor(1).
WithContent("News with default timestamp").
Create()
if err != nil {
t.Fatalf("Failed to create news with default timestamp: %v", err)
}
// Should have recent timestamp (within last minute)
if time.Since(defaultNews.PostedTime()) > time.Minute {
t.Error("Expected default timestamp to be recent")
}
}
func TestSave(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
news, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find news: %v", err)
}
// Modify news
news.Author = 999
news.Content = "Updated content"
news.Posted = time.Now().Unix()
// Save changes
err = news.Save()
if err != nil {
t.Fatalf("Failed to save news: %v", err)
}
// Verify changes were saved
updatedNews, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find updated news: %v", err)
}
if updatedNews.Author != 999 {
t.Errorf("Expected updated author 999, got %d", updatedNews.Author)
}
if updatedNews.Content != "Updated content" {
t.Errorf("Expected updated content, got '%s'", updatedNews.Content)
}
}
func TestDelete(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
news, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find news: %v", err)
}
// Delete news
err = news.Delete()
if err != nil {
t.Fatalf("Failed to delete news: %v", err)
}
// Verify news was deleted
_, err = Find(db, 1)
if err == nil {
t.Error("Expected error when finding deleted news")
}
}
func TestUtilityMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
news, _ := Find(db, 1)
// Test PostedTime
postedTime := news.PostedTime()
if postedTime.IsZero() {
t.Error("Expected non-zero posted time")
}
// Test IsRecent (should be false for old posts initially)
if news.IsRecent() {
t.Error("Expected old news post not to be recent")
}
// Test SetPostedTime
newTime := time.Now().Add(-2 * time.Hour)
news.SetPostedTime(newTime)
if news.Posted != newTime.Unix() {
t.Errorf("Expected posted timestamp %d, got %d", newTime.Unix(), news.Posted)
}
// Test IsRecent (should be true after setting to 2 hours ago)
if !news.IsRecent() {
t.Error("Expected news post from 2 hours ago to be recent")
}
// Create recent post
recentNews, _ := NewBuilder(db).
WithAuthor(1).
WithContent("Recent post").
Create()
if !recentNews.IsRecent() {
t.Error("Expected newly created post to be recent")
}
// Test Age
age := news.Age()
if age < 0 {
t.Error("Expected positive age")
}
// Test IsAuthor
if !news.IsAuthor(news.Author) {
t.Error("Expected IsAuthor to return true for correct author")
}
if news.IsAuthor(999) {
t.Error("Expected IsAuthor to return false for incorrect author")
}
// Test Preview
longContent := "This is a very long content that should be truncated when preview is called"
news.Content = longContent
preview := news.Preview(20)
if len(preview) > 20 {
t.Errorf("Expected preview length <= 20, got %d", len(preview))
}
if preview[len(preview)-3:] != "..." {
t.Error("Expected preview to end with ellipsis")
}
shortPreview := news.Preview(100) // Longer than content
if shortPreview != longContent {
t.Error("Expected short content to not be truncated")
}
// Test WordCount
news.Content = "This is a test with five words"
wordCount := news.WordCount()
if wordCount != 7 {
t.Errorf("Expected 7 words, got %d", wordCount)
}
news.Content = ""
emptyWordCount := news.WordCount()
if emptyWordCount != 0 {
t.Errorf("Expected 0 words for empty content, got %d", emptyWordCount)
}
news.Content = "OneWord"
oneWordCount := news.WordCount()
if oneWordCount != 1 {
t.Errorf("Expected 1 word, got %d", oneWordCount)
}
}

View File

@ -0,0 +1,88 @@
package spells
import (
"dk/internal/database"
"fmt"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating spells
type Builder struct {
spell *Spell
db *database.DB
}
// NewBuilder creates a new spell builder
func NewBuilder(db *database.DB) *Builder {
return &Builder{
spell: &Spell{db: db},
db: db,
}
}
// 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 := b.db.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,
db: b.db,
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to create spell: %w", err)
}
return spell, nil
}

267
internal/spells/doc.go Normal file
View File

@ -0,0 +1,267 @@
/*
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

265
internal/spells/spells.go Normal file
View File

@ -0,0 +1,265 @@
package spells
import (
"fmt"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Spell represents a spell in the database
type Spell struct {
ID int `json:"id"`
Name string `json:"name"`
MP int `json:"mp"`
Attribute int `json:"attribute"`
Type int `json:"type"`
db *database.DB
}
// SpellType constants for spell types
const (
TypeHealing = 1
TypeHurt = 2
TypeSleep = 3
TypeAttackBoost = 4
TypeDefenseBoost = 5
)
// Find retrieves a spell by ID
func Find(db *database.DB, id int) (*Spell, error) {
spell := &Spell{db: db}
query := "SELECT id, name, mp, attribute, type FROM spells WHERE id = ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
spell.ID = stmt.ColumnInt(0)
spell.Name = stmt.ColumnText(1)
spell.MP = stmt.ColumnInt(2)
spell.Attribute = stmt.ColumnInt(3)
spell.Type = stmt.ColumnInt(4)
return nil
}, id)
if err != nil {
return nil, fmt.Errorf("failed to find spell: %w", err)
}
if spell.ID == 0 {
return nil, fmt.Errorf("spell with ID %d not found", id)
}
return spell, nil
}
// All retrieves all spells
func All(db *database.DB) ([]*Spell, error) {
var spells []*Spell
query := "SELECT id, name, mp, attribute, type FROM spells ORDER BY type, mp, id"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
spell := &Spell{
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
MP: stmt.ColumnInt(2),
Attribute: stmt.ColumnInt(3),
Type: stmt.ColumnInt(4),
db: db,
}
spells = append(spells, spell)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to retrieve all spells: %w", err)
}
return spells, nil
}
// ByType retrieves spells by type
func ByType(db *database.DB, spellType int) ([]*Spell, error) {
var spells []*Spell
query := "SELECT id, name, mp, attribute, type FROM spells WHERE type = ? ORDER BY mp, id"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
spell := &Spell{
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
MP: stmt.ColumnInt(2),
Attribute: stmt.ColumnInt(3),
Type: stmt.ColumnInt(4),
db: db,
}
spells = append(spells, spell)
return nil
}, spellType)
if err != nil {
return nil, fmt.Errorf("failed to retrieve spells by type: %w", err)
}
return spells, nil
}
// ByMaxMP retrieves spells that cost at most the specified MP
func ByMaxMP(db *database.DB, maxMP int) ([]*Spell, error) {
var spells []*Spell
query := "SELECT id, name, mp, attribute, type FROM spells WHERE mp <= ? ORDER BY type, mp, id"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
spell := &Spell{
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
MP: stmt.ColumnInt(2),
Attribute: stmt.ColumnInt(3),
Type: stmt.ColumnInt(4),
db: db,
}
spells = append(spells, spell)
return nil
}, maxMP)
if err != nil {
return nil, fmt.Errorf("failed to retrieve spells by max MP: %w", err)
}
return spells, nil
}
// ByTypeAndMaxMP retrieves spells of a specific type that cost at most the specified MP
func ByTypeAndMaxMP(db *database.DB, spellType, maxMP int) ([]*Spell, error) {
var spells []*Spell
query := "SELECT id, name, mp, attribute, type FROM spells WHERE type = ? AND mp <= ? ORDER BY mp, id"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
spell := &Spell{
ID: stmt.ColumnInt(0),
Name: stmt.ColumnText(1),
MP: stmt.ColumnInt(2),
Attribute: stmt.ColumnInt(3),
Type: stmt.ColumnInt(4),
db: db,
}
spells = append(spells, spell)
return nil
}, spellType, maxMP)
if err != nil {
return nil, fmt.Errorf("failed to retrieve spells by type and max MP: %w", err)
}
return spells, nil
}
// ByName retrieves a spell by name (case-insensitive)
func ByName(db *database.DB, name string) (*Spell, error) {
spell := &Spell{db: db}
query := "SELECT id, name, mp, attribute, type FROM spells WHERE LOWER(name) = LOWER(?) LIMIT 1"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
spell.ID = stmt.ColumnInt(0)
spell.Name = stmt.ColumnText(1)
spell.MP = stmt.ColumnInt(2)
spell.Attribute = stmt.ColumnInt(3)
spell.Type = stmt.ColumnInt(4)
return nil
}, name)
if err != nil {
return nil, fmt.Errorf("failed to find spell by name: %w", err)
}
if spell.ID == 0 {
return nil, fmt.Errorf("spell with name '%s' not found", name)
}
return spell, nil
}
// Save updates an existing spell in the database
func (s *Spell) Save() error {
if s.ID == 0 {
return fmt.Errorf("cannot save spell without ID")
}
query := `UPDATE spells SET name = ?, mp = ?, attribute = ?, type = ? WHERE id = ?`
return s.db.Exec(query, s.Name, s.MP, s.Attribute, s.Type, s.ID)
}
// Delete removes the spell from the database
func (s *Spell) Delete() error {
if s.ID == 0 {
return fmt.Errorf("cannot delete spell without ID")
}
query := "DELETE FROM spells WHERE id = ?"
return s.db.Exec(query, s.ID)
}
// IsHealing returns true if the spell is a healing spell
func (s *Spell) IsHealing() bool {
return s.Type == TypeHealing
}
// IsHurt returns true if the spell is a hurt spell
func (s *Spell) IsHurt() bool {
return s.Type == TypeHurt
}
// IsSleep returns true if the spell is a sleep spell
func (s *Spell) IsSleep() bool {
return s.Type == TypeSleep
}
// IsAttackBoost returns true if the spell boosts attack
func (s *Spell) IsAttackBoost() bool {
return s.Type == TypeAttackBoost
}
// IsDefenseBoost returns true if the spell boosts defense
func (s *Spell) IsDefenseBoost() bool {
return s.Type == TypeDefenseBoost
}
// TypeName returns the string representation of the spell type
func (s *Spell) TypeName() string {
switch s.Type {
case TypeHealing:
return "Healing"
case TypeHurt:
return "Hurt"
case TypeSleep:
return "Sleep"
case TypeAttackBoost:
return "Attack Boost"
case TypeDefenseBoost:
return "Defense Boost"
default:
return "Unknown"
}
}
// CanCast returns true if the spell can be cast with the given MP
func (s *Spell) CanCast(availableMP int) bool {
return availableMP >= s.MP
}
// Efficiency returns the attribute per MP ratio (higher is more efficient)
func (s *Spell) Efficiency() float64 {
if s.MP == 0 {
return 0
}
return float64(s.Attribute) / float64(s.MP)
}
// IsOffensive returns true if the spell is used for attacking
func (s *Spell) IsOffensive() bool {
return s.Type == TypeHurt || s.Type == TypeSleep
}
// IsSupport returns true if the spell is used for support/buffs
func (s *Spell) IsSupport() bool {
return s.Type == TypeHealing || s.Type == TypeAttackBoost || s.Type == TypeDefenseBoost
}

View File

@ -0,0 +1,445 @@
package spells
import (
"os"
"testing"
"dk/internal/database"
)
func setupTestDB(t *testing.T) *database.DB {
testDB := "test_spells.db"
t.Cleanup(func() {
os.Remove(testDB)
})
db, err := database.Open(testDB)
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
// Create spells table
createTable := `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
)`
if err := db.Exec(createTable); err != nil {
t.Fatalf("Failed to create spells table: %v", err)
}
// Insert test data
testSpells := `INSERT INTO spells (name, mp, attribute, type) VALUES
('Heal', 5, 10, 1),
('Revive', 10, 25, 1),
('Hurt', 5, 15, 2),
('Pain', 12, 35, 2),
('Sleep', 10, 5, 3),
('Dream', 30, 9, 3),
('Craze', 10, 10, 4),
('Ward', 10, 10, 5)`
if err := db.Exec(testSpells); err != nil {
t.Fatalf("Failed to insert test spells: %v", err)
}
return db
}
func TestFind(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test finding existing spell
spell, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find spell: %v", err)
}
if spell.ID != 1 {
t.Errorf("Expected ID 1, got %d", spell.ID)
}
if spell.Name != "Heal" {
t.Errorf("Expected name 'Heal', got '%s'", spell.Name)
}
if spell.MP != 5 {
t.Errorf("Expected MP 5, got %d", spell.MP)
}
if spell.Attribute != 10 {
t.Errorf("Expected attribute 10, got %d", spell.Attribute)
}
if spell.Type != TypeHealing {
t.Errorf("Expected type %d, got %d", TypeHealing, spell.Type)
}
// Test finding non-existent spell
_, err = Find(db, 999)
if err == nil {
t.Error("Expected error when finding non-existent spell")
}
}
func TestAll(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
spells, err := All(db)
if err != nil {
t.Fatalf("Failed to get all spells: %v", err)
}
if len(spells) != 8 {
t.Errorf("Expected 8 spells, got %d", len(spells))
}
// Check ordering (by type, then MP, then ID)
if spells[0].Type > spells[1].Type {
t.Error("Expected spells to be ordered by type first")
}
}
func TestByType(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test healing spells
healingSpells, err := ByType(db, TypeHealing)
if err != nil {
t.Fatalf("Failed to get healing spells: %v", err)
}
if len(healingSpells) != 2 {
t.Errorf("Expected 2 healing spells, got %d", len(healingSpells))
}
for _, spell := range healingSpells {
if spell.Type != TypeHealing {
t.Errorf("Expected healing spell, got type %d", spell.Type)
}
}
// Test hurt spells
hurtSpells, err := ByType(db, TypeHurt)
if err != nil {
t.Fatalf("Failed to get hurt spells: %v", err)
}
if len(hurtSpells) != 2 {
t.Errorf("Expected 2 hurt spells, got %d", len(hurtSpells))
}
// Verify ordering within type (by MP)
if hurtSpells[0].MP > hurtSpells[1].MP {
t.Error("Expected spells within type to be ordered by MP")
}
}
func TestByMaxMP(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test spells with MP <= 10
lowMPSpells, err := ByMaxMP(db, 10)
if err != nil {
t.Fatalf("Failed to get low MP spells: %v", err)
}
expectedCount := 6 // Heal(5), Hurt(5), Sleep(10), Craze(10), Revive(10), Ward(10)
if len(lowMPSpells) != expectedCount {
t.Errorf("Expected %d spells with MP <= 10, got %d", expectedCount, len(lowMPSpells))
}
// Verify all spells have MP <= 10
for _, spell := range lowMPSpells {
if spell.MP > 10 {
t.Errorf("Spell %s has MP %d, expected <= 10", spell.Name, spell.MP)
}
}
// Test very low MP threshold
veryLowMPSpells, err := ByMaxMP(db, 5)
if err != nil {
t.Fatalf("Failed to get very low MP spells: %v", err)
}
if len(veryLowMPSpells) != 2 { // Only Heal and Hurt
t.Errorf("Expected 2 spells with MP <= 5, got %d", len(veryLowMPSpells))
}
}
func TestByTypeAndMaxMP(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test healing spells with MP <= 10
healingSpells, err := ByTypeAndMaxMP(db, TypeHealing, 10)
if err != nil {
t.Fatalf("Failed to get healing spells with MP <= 10: %v", err)
}
expectedCount := 2 // Heal(5) and Revive(10)
if len(healingSpells) != expectedCount {
t.Errorf("Expected %d healing spells with MP <= 10, got %d", expectedCount, len(healingSpells))
}
// Verify all are healing spells and within MP limit
for _, spell := range healingSpells {
if spell.Type != TypeHealing {
t.Errorf("Expected healing spell, got type %d", spell.Type)
}
if spell.MP > 10 {
t.Errorf("Spell %s has MP %d, expected <= 10", spell.Name, spell.MP)
}
}
// Test hurt spells with very low MP
lowHurtSpells, err := ByTypeAndMaxMP(db, TypeHurt, 5)
if err != nil {
t.Fatalf("Failed to get hurt spells with MP <= 5: %v", err)
}
if len(lowHurtSpells) != 1 { // Only Hurt(5)
t.Errorf("Expected 1 hurt spell with MP <= 5, got %d", len(lowHurtSpells))
}
}
func TestByName(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test finding existing spell by name
spell, err := ByName(db, "Heal")
if err != nil {
t.Fatalf("Failed to find spell by name: %v", err)
}
if spell.Name != "Heal" {
t.Errorf("Expected name 'Heal', got '%s'", spell.Name)
}
if spell.Type != TypeHealing {
t.Errorf("Expected healing spell, got type %d", spell.Type)
}
// Test case insensitivity
spellLower, err := ByName(db, "heal")
if err != nil {
t.Fatalf("Failed to find spell by lowercase name: %v", err)
}
if spellLower.ID != spell.ID {
t.Error("Case insensitive search should return same spell")
}
// Test non-existent spell
_, err = ByName(db, "Fireball")
if err == nil {
t.Error("Expected error when finding non-existent spell by name")
}
}
func TestBuilder(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Create new spell using builder
spell, err := NewBuilder(db).
WithName("Lightning").
WithMP(25).
WithAttribute(60).
WithType(TypeHurt).
Create()
if err != nil {
t.Fatalf("Failed to create spell with builder: %v", err)
}
if spell.ID == 0 {
t.Error("Expected non-zero ID after creation")
}
if spell.Name != "Lightning" {
t.Errorf("Expected name 'Lightning', got '%s'", spell.Name)
}
if spell.MP != 25 {
t.Errorf("Expected MP 25, got %d", spell.MP)
}
if spell.Attribute != 60 {
t.Errorf("Expected attribute 60, got %d", spell.Attribute)
}
if spell.Type != TypeHurt {
t.Errorf("Expected type %d, got %d", TypeHurt, spell.Type)
}
// Verify it was saved to database
foundSpell, err := Find(db, spell.ID)
if err != nil {
t.Fatalf("Failed to find created spell: %v", err)
}
if foundSpell.Name != "Lightning" {
t.Errorf("Created spell not found in database")
}
}
func TestSave(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
spell, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find spell: %v", err)
}
// Modify spell
spell.Name = "Enhanced Heal"
spell.MP = 7
spell.Attribute = 15
// Save changes
err = spell.Save()
if err != nil {
t.Fatalf("Failed to save spell: %v", err)
}
// Verify changes were saved
updatedSpell, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find updated spell: %v", err)
}
if updatedSpell.Name != "Enhanced Heal" {
t.Errorf("Expected updated name 'Enhanced Heal', got '%s'", updatedSpell.Name)
}
if updatedSpell.MP != 7 {
t.Errorf("Expected updated MP 7, got %d", updatedSpell.MP)
}
if updatedSpell.Attribute != 15 {
t.Errorf("Expected updated attribute 15, got %d", updatedSpell.Attribute)
}
}
func TestDelete(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
spell, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find spell: %v", err)
}
// Delete spell
err = spell.Delete()
if err != nil {
t.Fatalf("Failed to delete spell: %v", err)
}
// Verify spell was deleted
_, err = Find(db, 1)
if err == nil {
t.Error("Expected error when finding deleted spell")
}
}
func TestSpellTypeMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
heal, _ := Find(db, 1) // Healing
hurt, _ := Find(db, 3) // Hurt
sleep, _ := Find(db, 5) // Sleep
craze, _ := Find(db, 7) // Attack boost
ward, _ := Find(db, 8) // Defense boost
// Test type checking methods
if !heal.IsHealing() {
t.Error("Expected Heal to be healing spell")
}
if heal.IsHurt() {
t.Error("Expected Heal not to be hurt spell")
}
if !hurt.IsHurt() {
t.Error("Expected Hurt to be hurt spell")
}
if hurt.IsHealing() {
t.Error("Expected Hurt not to be healing spell")
}
if !sleep.IsSleep() {
t.Error("Expected Sleep to be sleep spell")
}
if !craze.IsAttackBoost() {
t.Error("Expected Craze to be attack boost spell")
}
if !ward.IsDefenseBoost() {
t.Error("Expected Ward to be defense boost spell")
}
// Test TypeName
if heal.TypeName() != "Healing" {
t.Errorf("Expected Heal type name 'Healing', got '%s'", heal.TypeName())
}
if hurt.TypeName() != "Hurt" {
t.Errorf("Expected Hurt type name 'Hurt', got '%s'", hurt.TypeName())
}
if sleep.TypeName() != "Sleep" {
t.Errorf("Expected Sleep type name 'Sleep', got '%s'", sleep.TypeName())
}
if craze.TypeName() != "Attack Boost" {
t.Errorf("Expected Craze type name 'Attack Boost', got '%s'", craze.TypeName())
}
if ward.TypeName() != "Defense Boost" {
t.Errorf("Expected Ward type name 'Defense Boost', got '%s'", ward.TypeName())
}
}
func TestUtilityMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
heal, _ := Find(db, 1) // MP: 5, Attribute: 10
hurt, _ := Find(db, 3) // MP: 5, Attribute: 15
sleep, _ := Find(db, 5) // MP: 10, Attribute: 5
// Test CanCast
if !heal.CanCast(10) {
t.Error("Expected to be able to cast Heal with 10 MP")
}
if heal.CanCast(3) {
t.Error("Expected not to be able to cast Heal with 3 MP")
}
// Test Efficiency
expectedHealEff := float64(10) / float64(5) // 2.0
if heal.Efficiency() != expectedHealEff {
t.Errorf("Expected Heal efficiency %.2f, got %.2f", expectedHealEff, heal.Efficiency())
}
expectedHurtEff := float64(15) / float64(5) // 3.0
if hurt.Efficiency() != expectedHurtEff {
t.Errorf("Expected Hurt efficiency %.2f, got %.2f", expectedHurtEff, hurt.Efficiency())
}
// Test IsOffensive
if heal.IsOffensive() {
t.Error("Expected Heal not to be offensive")
}
if !hurt.IsOffensive() {
t.Error("Expected Hurt to be offensive")
}
if !sleep.IsOffensive() {
t.Error("Expected Sleep to be offensive")
}
// Test IsSupport
if !heal.IsSupport() {
t.Error("Expected Heal to be support spell")
}
if hurt.IsSupport() {
t.Error("Expected Hurt not to be support spell")
}
}

115
internal/towns/builder.go Normal file
View File

@ -0,0 +1,115 @@
package towns
import (
"fmt"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating towns
type Builder struct {
town *Town
db *database.DB
}
// NewBuilder creates a new town builder
func NewBuilder(db *database.DB) *Builder {
return &Builder{
town: &Town{
db: db,
},
db: db,
}
}
// 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 := b.db.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
}

295
internal/towns/doc.go Normal file
View File

@ -0,0 +1,295 @@
/*
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

268
internal/towns/towns.go Normal file
View File

@ -0,0 +1,268 @@
package towns
import (
"fmt"
"strings"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Town represents a town in the database
type Town struct {
ID int `json:"id"`
Name string `json:"name"`
X int `json:"x"`
Y int `json:"y"`
InnCost int `json:"inn_cost"`
MapCost int `json:"map_cost"`
TPCost int `json:"tp_cost"`
ShopList string `json:"shop_list"`
db *database.DB
}
// Find retrieves a town by ID
func Find(db *database.DB, id int) (*Town, error) {
town := &Town{db: db}
query := "SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list FROM towns WHERE id = ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
town.ID = stmt.ColumnInt(0)
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
}, id)
if err != nil {
return nil, fmt.Errorf("failed to find town: %w", err)
}
if town.ID == 0 {
return nil, fmt.Errorf("town with ID %d not found", id)
}
return town, nil
}
// All retrieves all towns
func All(db *database.DB) ([]*Town, error) {
var towns []*Town
query := "SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list FROM towns ORDER BY id"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
town := &Town{
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),
db: db,
}
towns = append(towns, town)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to retrieve all towns: %w", err)
}
return towns, nil
}
// ByName retrieves a town by name (case-insensitive)
func ByName(db *database.DB, name string) (*Town, error) {
town := &Town{db: db}
query := "SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list FROM towns WHERE LOWER(name) = LOWER(?) LIMIT 1"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
town.ID = stmt.ColumnInt(0)
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
}, name)
if err != nil {
return nil, fmt.Errorf("failed to find town by name: %w", err)
}
if town.ID == 0 {
return nil, fmt.Errorf("town with name '%s' not found", name)
}
return town, nil
}
// ByMaxInnCost retrieves towns with inn cost at most the specified amount
func ByMaxInnCost(db *database.DB, maxCost int) ([]*Town, error) {
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"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
town := &Town{
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),
db: db,
}
towns = append(towns, town)
return nil
}, maxCost)
if err != nil {
return nil, fmt.Errorf("failed to retrieve towns by max inn cost: %w", err)
}
return towns, nil
}
// ByMaxTPCost retrieves towns with teleport cost at most the specified amount
func ByMaxTPCost(db *database.DB, maxCost int) ([]*Town, error) {
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"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
town := &Town{
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),
db: db,
}
towns = append(towns, town)
return nil
}, maxCost)
if err != nil {
return nil, fmt.Errorf("failed to retrieve towns by max TP cost: %w", err)
}
return towns, nil
}
// ByDistance retrieves towns within a certain distance from a point
func ByDistance(db *database.DB, fromX, fromY, maxDistance int) ([]*Town, error) {
var towns []*Town
query := `SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list
FROM towns
WHERE ((x - ?) * (x - ?) + (y - ?) * (y - ?)) <= ?
ORDER BY ((x - ?) * (x - ?) + (y - ?) * (y - ?)), id`
maxDistance2 := maxDistance * maxDistance
err := db.Query(query, func(stmt *sqlite.Stmt) error {
town := &Town{
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),
db: db,
}
towns = append(towns, town)
return nil
}, fromX, fromX, fromY, fromY, maxDistance2, fromX, fromX, fromY, fromY)
if err != nil {
return nil, fmt.Errorf("failed to retrieve towns by distance: %w", err)
}
return towns, nil
}
// Save updates an existing town in the database
func (t *Town) Save() error {
if t.ID == 0 {
return fmt.Errorf("cannot save town without ID")
}
query := `UPDATE towns SET name = ?, x = ?, y = ?, inn_cost = ?, map_cost = ?, tp_cost = ?, shop_list = ? WHERE id = ?`
return t.db.Exec(query, t.Name, t.X, t.Y, t.InnCost, t.MapCost, t.TPCost, t.ShopList, t.ID)
}
// Delete removes the town from the database
func (t *Town) Delete() error {
if t.ID == 0 {
return fmt.Errorf("cannot delete town without ID")
}
query := "DELETE FROM towns WHERE id = ?"
return t.db.Exec(query, t.ID)
}
// GetShopItems returns the shop items as a slice of item IDs
func (t *Town) GetShopItems() []string {
if t.ShopList == "" {
return []string{}
}
return strings.Split(t.ShopList, ",")
}
// SetShopItems sets the shop items from a slice of item IDs
func (t *Town) SetShopItems(items []string) {
t.ShopList = strings.Join(items, ",")
}
// HasShopItem checks if the town's shop sells a specific item ID
func (t *Town) HasShopItem(itemID string) bool {
items := t.GetShopItems()
for _, item := range items {
if strings.TrimSpace(item) == itemID {
return true
}
}
return false
}
// DistanceFrom calculates the distance from this town to given coordinates
func (t *Town) DistanceFrom(x, y int) float64 {
dx := float64(t.X - x)
dy := float64(t.Y - y)
return dx*dx + dy*dy // Return squared distance for performance
}
// IsStartingTown returns true if this is the starting town (Midworld)
func (t *Town) IsStartingTown() bool {
return t.X == 0 && t.Y == 0
}
// CanAffordInn returns true if the player can afford the inn
func (t *Town) CanAffordInn(gold int) bool {
return gold >= t.InnCost
}
// CanAffordMap returns true if the player can afford to buy the map
func (t *Town) CanAffordMap(gold int) bool {
return gold >= t.MapCost
}
// CanAffordTeleport returns true if the player can afford to teleport here
func (t *Town) CanAffordTeleport(gold int) bool {
return gold >= t.TPCost
}

View File

@ -0,0 +1,440 @@
package towns
import (
"os"
"testing"
"dk/internal/database"
)
func setupTestDB(t *testing.T) *database.DB {
testDB := "test_towns.db"
t.Cleanup(func() {
os.Remove(testDB)
})
db, err := database.Open(testDB)
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
// Create towns table
createTable := `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 ''
)`
if err := db.Exec(createTable); err != nil {
t.Fatalf("Failed to create towns table: %v", err)
}
// Insert test data
testTowns := `INSERT INTO towns (name, x, y, inn_cost, map_cost, tp_cost, shop_list) VALUES
('Midworld', 0, 0, 5, 0, 0, '1,2,3,17,18,19'),
('Roma', 30, 30, 10, 25, 5, '2,3,4,18,19,29'),
('Bris', 70, -70, 25, 50, 15, '2,3,4,5,18,19,20'),
('Kalle', -100, 100, 40, 100, 30, '5,6,8,10,12,21,22,23'),
('Endworld', -250, -250, 125, 9000, 160, '16,27,33')`
if err := db.Exec(testTowns); err != nil {
t.Fatalf("Failed to insert test towns: %v", err)
}
return db
}
func TestFind(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test finding existing town
town, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find town: %v", err)
}
if town.ID != 1 {
t.Errorf("Expected ID 1, got %d", town.ID)
}
if town.Name != "Midworld" {
t.Errorf("Expected name 'Midworld', got '%s'", town.Name)
}
if town.X != 0 {
t.Errorf("Expected X 0, got %d", town.X)
}
if town.Y != 0 {
t.Errorf("Expected Y 0, got %d", town.Y)
}
if town.InnCost != 5 {
t.Errorf("Expected inn_cost 5, got %d", town.InnCost)
}
if town.MapCost != 0 {
t.Errorf("Expected map_cost 0, got %d", town.MapCost)
}
if town.TPCost != 0 {
t.Errorf("Expected tp_cost 0, got %d", town.TPCost)
}
if town.ShopList != "1,2,3,17,18,19" {
t.Errorf("Expected shop_list '1,2,3,17,18,19', got '%s'", town.ShopList)
}
// Test finding non-existent town
_, err = Find(db, 999)
if err == nil {
t.Error("Expected error when finding non-existent town")
}
}
func TestAll(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
towns, err := All(db)
if err != nil {
t.Fatalf("Failed to get all towns: %v", err)
}
if len(towns) != 5 {
t.Errorf("Expected 5 towns, got %d", len(towns))
}
// Check ordering (by ID)
if towns[0].Name != "Midworld" {
t.Errorf("Expected first town to be 'Midworld', got '%s'", towns[0].Name)
}
}
func TestByName(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test finding existing town by name
town, err := ByName(db, "Midworld")
if err != nil {
t.Fatalf("Failed to find town by name: %v", err)
}
if town.Name != "Midworld" {
t.Errorf("Expected name 'Midworld', got '%s'", town.Name)
}
if town.X != 0 || town.Y != 0 {
t.Errorf("Expected coordinates (0,0), got (%d,%d)", town.X, town.Y)
}
// Test case insensitivity
townLower, err := ByName(db, "midworld")
if err != nil {
t.Fatalf("Failed to find town by lowercase name: %v", err)
}
if townLower.ID != town.ID {
t.Error("Case insensitive search should return same town")
}
// Test non-existent town
_, err = ByName(db, "Atlantis")
if err == nil {
t.Error("Expected error when finding non-existent town by name")
}
}
func TestByMaxInnCost(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test towns with inn cost <= 10
cheapInns, err := ByMaxInnCost(db, 10)
if err != nil {
t.Fatalf("Failed to get towns by max inn cost: %v", err)
}
expectedCount := 2 // Midworld(5) and Roma(10)
if len(cheapInns) != expectedCount {
t.Errorf("Expected %d towns with inn cost <= 10, got %d", expectedCount, len(cheapInns))
}
// Verify all towns have inn cost <= 10
for _, town := range cheapInns {
if town.InnCost > 10 {
t.Errorf("Town %s has inn cost %d, expected <= 10", town.Name, town.InnCost)
}
}
// Verify ordering (by inn cost, then ID)
if cheapInns[0].InnCost > cheapInns[1].InnCost {
t.Error("Expected towns to be ordered by inn cost")
}
}
func TestByMaxTPCost(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test towns with TP cost <= 15
cheapTP, err := ByMaxTPCost(db, 15)
if err != nil {
t.Fatalf("Failed to get towns by max TP cost: %v", err)
}
expectedCount := 3 // Midworld(0), Roma(5), Bris(15)
if len(cheapTP) != expectedCount {
t.Errorf("Expected %d towns with TP cost <= 15, got %d", expectedCount, len(cheapTP))
}
// Verify all towns have TP cost <= 15
for _, town := range cheapTP {
if town.TPCost > 15 {
t.Errorf("Town %s has TP cost %d, expected <= 15", town.Name, town.TPCost)
}
}
}
func TestByDistance(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test towns within distance 50 from origin (0,0)
nearbyTowns, err := ByDistance(db, 0, 0, 50)
if err != nil {
t.Fatalf("Failed to get towns by distance: %v", err)
}
// Midworld (0,0) distance=0, Roma (30,30) distance=sqrt(1800)≈42.4
expectedCount := 2
if len(nearbyTowns) != expectedCount {
t.Errorf("Expected %d towns within distance 50, got %d", expectedCount, len(nearbyTowns))
}
// Verify distances are within limit
for _, town := range nearbyTowns {
distance := town.DistanceFrom(0, 0)
if distance > 50*50 { // Using squared distance
t.Errorf("Town %s distance %.2f is beyond limit", town.Name, distance)
}
}
// Verify ordering (by distance)
if len(nearbyTowns) >= 2 {
dist1 := nearbyTowns[0].DistanceFrom(0, 0)
dist2 := nearbyTowns[1].DistanceFrom(0, 0)
if dist1 > dist2 {
t.Error("Expected towns to be ordered by distance")
}
}
}
func TestBuilder(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Create new town using builder
town, err := NewBuilder(db).
WithName("Test City").
WithCoordinates(100, -50).
WithInnCost(20).
WithMapCost(75).
WithTPCost(25).
WithShopItems([]string{"1", "2", "10", "15"}).
Create()
if err != nil {
t.Fatalf("Failed to create town with builder: %v", err)
}
if town.ID == 0 {
t.Error("Expected non-zero ID after creation")
}
if town.Name != "Test City" {
t.Errorf("Expected name 'Test City', got '%s'", town.Name)
}
if town.X != 100 {
t.Errorf("Expected X 100, got %d", town.X)
}
if town.Y != -50 {
t.Errorf("Expected Y -50, got %d", town.Y)
}
if town.InnCost != 20 {
t.Errorf("Expected inn cost 20, got %d", town.InnCost)
}
if town.MapCost != 75 {
t.Errorf("Expected map cost 75, got %d", town.MapCost)
}
if town.TPCost != 25 {
t.Errorf("Expected TP cost 25, got %d", town.TPCost)
}
if town.ShopList != "1,2,10,15" {
t.Errorf("Expected shop list '1,2,10,15', got '%s'", town.ShopList)
}
// Verify it was saved to database
foundTown, err := Find(db, town.ID)
if err != nil {
t.Fatalf("Failed to find created town: %v", err)
}
if foundTown.Name != "Test City" {
t.Errorf("Created town not found in database")
}
}
func TestSave(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
town, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find town: %v", err)
}
// Modify town
town.Name = "Updated Midworld"
town.X = 5
town.Y = -5
town.InnCost = 8
// Save changes
err = town.Save()
if err != nil {
t.Fatalf("Failed to save town: %v", err)
}
// Verify changes were saved
updatedTown, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find updated town: %v", err)
}
if updatedTown.Name != "Updated Midworld" {
t.Errorf("Expected updated name 'Updated Midworld', got '%s'", updatedTown.Name)
}
if updatedTown.X != 5 {
t.Errorf("Expected updated X 5, got %d", updatedTown.X)
}
if updatedTown.Y != -5 {
t.Errorf("Expected updated Y -5, got %d", updatedTown.Y)
}
if updatedTown.InnCost != 8 {
t.Errorf("Expected updated inn cost 8, got %d", updatedTown.InnCost)
}
}
func TestDelete(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
town, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find town: %v", err)
}
// Delete town
err = town.Delete()
if err != nil {
t.Fatalf("Failed to delete town: %v", err)
}
// Verify town was deleted
_, err = Find(db, 1)
if err == nil {
t.Error("Expected error when finding deleted town")
}
}
func TestShopItemMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
town, _ := Find(db, 1) // Midworld with shop_list "1,2,3,17,18,19"
// Test GetShopItems
items := town.GetShopItems()
expectedItems := []string{"1", "2", "3", "17", "18", "19"}
if len(items) != len(expectedItems) {
t.Errorf("Expected %d shop items, got %d", len(expectedItems), len(items))
}
for i, expected := range expectedItems {
if i < len(items) && items[i] != expected {
t.Errorf("Expected item %s at position %d, got %s", expected, i, items[i])
}
}
// Test HasShopItem
if !town.HasShopItem("1") {
t.Error("Expected town to have shop item '1'")
}
if !town.HasShopItem("19") {
t.Error("Expected town to have shop item '19'")
}
if town.HasShopItem("99") {
t.Error("Expected town not to have shop item '99'")
}
// Test SetShopItems
newItems := []string{"5", "10", "15"}
town.SetShopItems(newItems)
if town.ShopList != "5,10,15" {
t.Errorf("Expected shop list '5,10,15', got '%s'", town.ShopList)
}
// Test with empty shop list
emptyTown, _ := Find(db, 5) // Create a town with empty shop list
emptyTown.ShopList = ""
emptyItems := emptyTown.GetShopItems()
if len(emptyItems) != 0 {
t.Errorf("Expected 0 items for empty shop list, got %d", len(emptyItems))
}
}
func TestUtilityMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
midworld, _ := Find(db, 1)
roma, _ := Find(db, 2)
// Test DistanceFrom
distance := roma.DistanceFrom(0, 0) // Roma is at (30,30)
expectedDistance := float64(30*30 + 30*30) // 1800
if distance != expectedDistance {
t.Errorf("Expected distance %.2f, got %.2f", expectedDistance, distance)
}
// Test IsStartingTown
if !midworld.IsStartingTown() {
t.Error("Expected Midworld to be starting town")
}
if roma.IsStartingTown() {
t.Error("Expected Roma not to be starting town")
}
// Test CanAffordInn
if !midworld.CanAffordInn(10) {
t.Error("Expected to afford Midworld inn with 10 gold (cost 5)")
}
if midworld.CanAffordInn(3) {
t.Error("Expected not to afford Midworld inn with 3 gold (cost 5)")
}
// Test CanAffordMap
if !roma.CanAffordMap(30) {
t.Error("Expected to afford Roma map with 30 gold (cost 25)")
}
if roma.CanAffordMap(20) {
t.Error("Expected not to afford Roma map with 20 gold (cost 25)")
}
// Test CanAffordTeleport
if !roma.CanAffordTeleport(10) {
t.Error("Expected to afford Roma teleport with 10 gold (cost 5)")
}
if roma.CanAffordTeleport(3) {
t.Error("Expected not to afford Roma teleport with 3 gold (cost 5)")
}
}

235
internal/users/builder.go Normal file
View File

@ -0,0 +1,235 @@
package users
import (
"fmt"
"time"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating users
type Builder struct {
user *User
db *database.DB
}
// NewBuilder creates a new user builder with default values
func NewBuilder(db *database.DB) *Builder {
now := time.Now().Unix()
return &Builder{
user: &User{
db: db,
Verified: 0, // Default unverified
Token: "", // Empty verification token
Registered: now, // Current time
LastOnline: now, // Current time
Auth: 0, // Default no special permissions
X: 0, // Default starting position
Y: 0, // Default starting position
ClassID: 1, // Default to class 1
Currently: "In Town", // Default status
Fighting: 0, // Default not fighting
HP: 15, // Default starting HP
MP: 0, // Default starting MP
TP: 10, // Default starting TP
MaxHP: 15, // Default starting max HP
MaxMP: 0, // Default starting max MP
MaxTP: 10, // Default starting max TP
Level: 1, // Default starting level
Gold: 100, // Default starting gold
Exp: 0, // Default starting exp
Strength: 5, // Default starting strength
Dexterity: 5, // Default starting dexterity
Attack: 5, // Default starting attack
Defense: 5, // Default starting defense
Spells: "", // No spells initially
Towns: "", // No towns visited initially
},
db: db,
}
}
// WithUsername sets the username
func (b *Builder) WithUsername(username string) *Builder {
b.user.Username = username
return b
}
// WithPassword sets the password
func (b *Builder) WithPassword(password string) *Builder {
b.user.Password = password
return b
}
// WithEmail sets the email address
func (b *Builder) WithEmail(email string) *Builder {
b.user.Email = email
return b
}
// WithVerified sets the verification status
func (b *Builder) WithVerified(verified bool) *Builder {
if verified {
b.user.Verified = 1
} else {
b.user.Verified = 0
}
return b
}
// WithToken sets the verification token
func (b *Builder) WithToken(token string) *Builder {
b.user.Token = token
return b
}
// WithAuth sets the authorization level
func (b *Builder) WithAuth(auth int) *Builder {
b.user.Auth = auth
return b
}
// AsAdmin sets the user as admin (auth = 4)
func (b *Builder) AsAdmin() *Builder {
b.user.Auth = 4
return b
}
// AsModerator sets the user as moderator (auth = 2)
func (b *Builder) AsModerator() *Builder {
b.user.Auth = 2
return b
}
// WithClassID sets the character class ID
func (b *Builder) WithClassID(classID int) *Builder {
b.user.ClassID = classID
return b
}
// WithPosition sets the starting coordinates
func (b *Builder) WithPosition(x, y int) *Builder {
b.user.X = x
b.user.Y = y
return b
}
// WithLevel sets the starting level
func (b *Builder) WithLevel(level int) *Builder {
b.user.Level = level
return b
}
// WithGold sets the starting gold amount
func (b *Builder) WithGold(gold int) *Builder {
b.user.Gold = gold
return b
}
// WithStats sets the core character stats
func (b *Builder) WithStats(strength, dexterity, attack, defense int) *Builder {
b.user.Strength = strength
b.user.Dexterity = dexterity
b.user.Attack = attack
b.user.Defense = defense
return b
}
// WithHP sets current and maximum HP
func (b *Builder) WithHP(hp, maxHP int) *Builder {
b.user.HP = hp
b.user.MaxHP = maxHP
return b
}
// WithMP sets current and maximum MP
func (b *Builder) WithMP(mp, maxMP int) *Builder {
b.user.MP = mp
b.user.MaxMP = maxMP
return b
}
// WithTP sets current and maximum TP
func (b *Builder) WithTP(tp, maxTP int) *Builder {
b.user.TP = tp
b.user.MaxTP = maxTP
return b
}
// WithCurrently sets the current status message
func (b *Builder) WithCurrently(currently string) *Builder {
b.user.Currently = currently
return b
}
// WithRegistered sets the registration timestamp
func (b *Builder) WithRegistered(registered int64) *Builder {
b.user.Registered = registered
return b
}
// WithRegisteredTime sets the registration timestamp from time.Time
func (b *Builder) WithRegisteredTime(t time.Time) *Builder {
b.user.Registered = t.Unix()
return b
}
// WithSpells sets the user's known spells
func (b *Builder) WithSpells(spells []string) *Builder {
b.user.SetSpellIDs(spells)
return b
}
// WithTowns sets the user's visited towns
func (b *Builder) WithTowns(towns []string) *Builder {
b.user.SetTownIDs(towns)
return b
}
// Create saves the user to the database and returns the created user with ID
func (b *Builder) Create() (*User, error) {
// Use a transaction to ensure we can get the ID
var user *User
err := b.db.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO users (username, password, email, verified, token, registered, last_online, auth,
x, y, class_id, currently, fighting, monster_id, monster_hp, monster_sleep, monster_immune,
uber_damage, uber_defense, hp, mp, tp, max_hp, max_mp, max_tp, level, gold, exp,
gold_bonus, exp_bonus, strength, dexterity, attack, defense, weapon_id, armor_id, shield_id,
slot_1_id, slot_2_id, slot_3_id, weapon_name, armor_name, shield_name,
slot_1_name, slot_2_name, slot_3_name, drop_code, spells, towns)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if err := tx.Exec(query, b.user.Username, b.user.Password, b.user.Email, b.user.Verified, b.user.Token,
b.user.Registered, b.user.LastOnline, b.user.Auth, b.user.X, b.user.Y, b.user.ClassID, b.user.Currently,
b.user.Fighting, b.user.MonsterID, b.user.MonsterHP, b.user.MonsterSleep, b.user.MonsterImmune,
b.user.UberDamage, b.user.UberDefense, b.user.HP, b.user.MP, b.user.TP, b.user.MaxHP, b.user.MaxMP, b.user.MaxTP,
b.user.Level, b.user.Gold, b.user.Exp, b.user.GoldBonus, b.user.ExpBonus, b.user.Strength, b.user.Dexterity,
b.user.Attack, b.user.Defense, b.user.WeaponID, b.user.ArmorID, b.user.ShieldID, b.user.Slot1ID,
b.user.Slot2ID, b.user.Slot3ID, b.user.WeaponName, b.user.ArmorName, b.user.ShieldName,
b.user.Slot1Name, b.user.Slot2Name, b.user.Slot3Name, b.user.DropCode, b.user.Spells, b.user.Towns); err != nil {
return fmt.Errorf("failed to insert user: %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.user.ID = id
user = b.user
return nil
})
if err != nil {
return nil, err
}
return user, nil
}

500
internal/users/doc.go Normal file
View File

@ -0,0 +1,500 @@
/*
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]interface{})
armor := equipment["armor"].(map[string]interface{})
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

424
internal/users/users.go Normal file
View File

@ -0,0 +1,424 @@
package users
import (
"fmt"
"strings"
"time"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// User represents a user in the database
type User struct {
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
Verified int `json:"verified"`
Token string `json:"token"`
Registered int64 `json:"registered"`
LastOnline int64 `json:"last_online"`
Auth int `json:"auth"`
X int `json:"x"`
Y int `json:"y"`
ClassID int `json:"class_id"`
Currently string `json:"currently"`
Fighting int `json:"fighting"`
MonsterID int `json:"monster_id"`
MonsterHP int `json:"monster_hp"`
MonsterSleep int `json:"monster_sleep"`
MonsterImmune int `json:"monster_immune"`
UberDamage int `json:"uber_damage"`
UberDefense int `json:"uber_defense"`
HP int `json:"hp"`
MP int `json:"mp"`
TP int `json:"tp"`
MaxHP int `json:"max_hp"`
MaxMP int `json:"max_mp"`
MaxTP int `json:"max_tp"`
Level int `json:"level"`
Gold int `json:"gold"`
Exp int `json:"exp"`
GoldBonus int `json:"gold_bonus"`
ExpBonus int `json:"exp_bonus"`
Strength int `json:"strength"`
Dexterity int `json:"dexterity"`
Attack int `json:"attack"`
Defense int `json:"defense"`
WeaponID int `json:"weapon_id"`
ArmorID int `json:"armor_id"`
ShieldID int `json:"shield_id"`
Slot1ID int `json:"slot_1_id"`
Slot2ID int `json:"slot_2_id"`
Slot3ID int `json:"slot_3_id"`
WeaponName string `json:"weapon_name"`
ArmorName string `json:"armor_name"`
ShieldName string `json:"shield_name"`
Slot1Name string `json:"slot_1_name"`
Slot2Name string `json:"slot_2_name"`
Slot3Name string `json:"slot_3_name"`
DropCode int `json:"drop_code"`
Spells string `json:"spells"`
Towns string `json:"towns"`
db *database.DB
}
// userColumns returns the column list for user queries (excluding id for inserts)
func userColumns() string {
return `id, username, password, email, verified, token, registered, last_online, auth,
x, y, class_id, currently, fighting, monster_id, monster_hp, monster_sleep, monster_immune,
uber_damage, uber_defense, hp, mp, tp, max_hp, max_mp, max_tp, level, gold, exp,
gold_bonus, exp_bonus, strength, dexterity, attack, defense, weapon_id, armor_id, shield_id,
slot_1_id, slot_2_id, slot_3_id, weapon_name, armor_name, shield_name,
slot_1_name, slot_2_name, slot_3_name, drop_code, spells, towns`
}
// scanUser populates a User struct from a sqlite.Stmt
func scanUser(stmt *sqlite.Stmt, db *database.DB) *User {
return &User{
ID: stmt.ColumnInt(0),
Username: stmt.ColumnText(1),
Password: stmt.ColumnText(2),
Email: stmt.ColumnText(3),
Verified: stmt.ColumnInt(4),
Token: stmt.ColumnText(5),
Registered: stmt.ColumnInt64(6),
LastOnline: stmt.ColumnInt64(7),
Auth: stmt.ColumnInt(8),
X: stmt.ColumnInt(9),
Y: stmt.ColumnInt(10),
ClassID: stmt.ColumnInt(11),
Currently: stmt.ColumnText(12),
Fighting: stmt.ColumnInt(13),
MonsterID: stmt.ColumnInt(14),
MonsterHP: stmt.ColumnInt(15),
MonsterSleep: stmt.ColumnInt(16),
MonsterImmune: stmt.ColumnInt(17),
UberDamage: stmt.ColumnInt(18),
UberDefense: stmt.ColumnInt(19),
HP: stmt.ColumnInt(20),
MP: stmt.ColumnInt(21),
TP: stmt.ColumnInt(22),
MaxHP: stmt.ColumnInt(23),
MaxMP: stmt.ColumnInt(24),
MaxTP: stmt.ColumnInt(25),
Level: stmt.ColumnInt(26),
Gold: stmt.ColumnInt(27),
Exp: stmt.ColumnInt(28),
GoldBonus: stmt.ColumnInt(29),
ExpBonus: stmt.ColumnInt(30),
Strength: stmt.ColumnInt(31),
Dexterity: stmt.ColumnInt(32),
Attack: stmt.ColumnInt(33),
Defense: stmt.ColumnInt(34),
WeaponID: stmt.ColumnInt(35),
ArmorID: stmt.ColumnInt(36),
ShieldID: stmt.ColumnInt(37),
Slot1ID: stmt.ColumnInt(38),
Slot2ID: stmt.ColumnInt(39),
Slot3ID: stmt.ColumnInt(40),
WeaponName: stmt.ColumnText(41),
ArmorName: stmt.ColumnText(42),
ShieldName: stmt.ColumnText(43),
Slot1Name: stmt.ColumnText(44),
Slot2Name: stmt.ColumnText(45),
Slot3Name: stmt.ColumnText(46),
DropCode: stmt.ColumnInt(47),
Spells: stmt.ColumnText(48),
Towns: stmt.ColumnText(49),
db: db,
}
}
// Find retrieves a user by ID
func Find(db *database.DB, id int) (*User, error) {
var user *User
query := `SELECT ` + userColumns() + ` FROM users WHERE id = ?`
err := db.Query(query, func(stmt *sqlite.Stmt) error {
user = scanUser(stmt, db)
return nil
}, id)
if err != nil {
return nil, fmt.Errorf("failed to find user: %w", err)
}
if user == nil {
return nil, fmt.Errorf("user with ID %d not found", id)
}
return user, nil
}
// All retrieves all users ordered by registration date (newest first)
func All(db *database.DB) ([]*User, error) {
var users []*User
query := `SELECT ` + userColumns() + ` FROM users ORDER BY registered DESC, id DESC`
err := db.Query(query, func(stmt *sqlite.Stmt) error {
user := scanUser(stmt, db)
users = append(users, user)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to retrieve all users: %w", err)
}
return users, nil
}
// ByUsername retrieves a user by username (case-insensitive)
func ByUsername(db *database.DB, username string) (*User, error) {
var user *User
query := `SELECT ` + userColumns() + ` FROM users WHERE LOWER(username) = LOWER(?) LIMIT 1`
err := db.Query(query, func(stmt *sqlite.Stmt) error {
user = scanUser(stmt, db)
return nil
}, username)
if err != nil {
return nil, fmt.Errorf("failed to find user by username: %w", err)
}
if user == nil {
return nil, fmt.Errorf("user with username '%s' not found", username)
}
return user, nil
}
// ByEmail retrieves a user by email address
func ByEmail(db *database.DB, email string) (*User, error) {
var user *User
query := `SELECT ` + userColumns() + ` FROM users WHERE email = ? LIMIT 1`
err := db.Query(query, func(stmt *sqlite.Stmt) error {
user = scanUser(stmt, db)
return nil
}, email)
if err != nil {
return nil, fmt.Errorf("failed to find user by email: %w", err)
}
if user == nil {
return nil, fmt.Errorf("user with email '%s' not found", email)
}
return user, nil
}
// ByLevel retrieves users at a specific level
func ByLevel(db *database.DB, level int) ([]*User, error) {
var users []*User
query := `SELECT ` + userColumns() + ` FROM users WHERE level = ? ORDER BY exp DESC, id ASC`
err := db.Query(query, func(stmt *sqlite.Stmt) error {
user := scanUser(stmt, db)
users = append(users, user)
return nil
}, level)
if err != nil {
return nil, fmt.Errorf("failed to retrieve users by level: %w", err)
}
return users, nil
}
// Online retrieves users who have been online within the specified duration
func Online(db *database.DB, within time.Duration) ([]*User, error) {
var users []*User
cutoff := time.Now().Add(-within).Unix()
query := `SELECT ` + userColumns() + ` FROM users WHERE last_online >= ? ORDER BY last_online DESC, id ASC`
err := db.Query(query, func(stmt *sqlite.Stmt) error {
user := scanUser(stmt, db)
users = append(users, user)
return nil
}, cutoff)
if err != nil {
return nil, fmt.Errorf("failed to retrieve online users: %w", err)
}
return users, nil
}
// Save updates an existing user in the database
func (u *User) Save() error {
if u.ID == 0 {
return fmt.Errorf("cannot save user without ID")
}
query := `UPDATE users SET username = ?, password = ?, email = ?, verified = ?, token = ?,
registered = ?, last_online = ?, auth = ?, x = ?, y = ?, class_id = ?, currently = ?,
fighting = ?, monster_id = ?, monster_hp = ?, monster_sleep = ?, monster_immune = ?,
uber_damage = ?, uber_defense = ?, hp = ?, mp = ?, tp = ?, max_hp = ?, max_mp = ?, max_tp = ?,
level = ?, gold = ?, exp = ?, gold_bonus = ?, exp_bonus = ?, strength = ?, dexterity = ?,
attack = ?, defense = ?, weapon_id = ?, armor_id = ?, shield_id = ?, slot_1_id = ?,
slot_2_id = ?, slot_3_id = ?, weapon_name = ?, armor_name = ?, shield_name = ?,
slot_1_name = ?, slot_2_name = ?, slot_3_name = ?, drop_code = ?, spells = ?, towns = ?
WHERE id = ?`
return u.db.Exec(query, u.Username, u.Password, u.Email, u.Verified, u.Token,
u.Registered, u.LastOnline, u.Auth, u.X, u.Y, u.ClassID, u.Currently,
u.Fighting, u.MonsterID, u.MonsterHP, u.MonsterSleep, u.MonsterImmune,
u.UberDamage, u.UberDefense, u.HP, u.MP, u.TP, u.MaxHP, u.MaxMP, u.MaxTP,
u.Level, u.Gold, u.Exp, u.GoldBonus, u.ExpBonus, u.Strength, u.Dexterity,
u.Attack, u.Defense, u.WeaponID, u.ArmorID, u.ShieldID, u.Slot1ID,
u.Slot2ID, u.Slot3ID, u.WeaponName, u.ArmorName, u.ShieldName,
u.Slot1Name, u.Slot2Name, u.Slot3Name, u.DropCode, u.Spells, u.Towns, u.ID)
}
// Delete removes the user from the database
func (u *User) Delete() error {
if u.ID == 0 {
return fmt.Errorf("cannot delete user without ID")
}
query := "DELETE FROM users WHERE id = ?"
return u.db.Exec(query, u.ID)
}
// RegisteredTime returns the registration timestamp as a time.Time
func (u *User) RegisteredTime() time.Time {
return time.Unix(u.Registered, 0)
}
// LastOnlineTime returns the last online timestamp as a time.Time
func (u *User) LastOnlineTime() time.Time {
return time.Unix(u.LastOnline, 0)
}
// UpdateLastOnline sets the last online timestamp to current time
func (u *User) UpdateLastOnline() {
u.LastOnline = time.Now().Unix()
}
// IsVerified returns true if the user's email is verified
func (u *User) IsVerified() bool {
return u.Verified == 1
}
// IsAdmin returns true if the user has admin privileges (auth >= 4)
func (u *User) IsAdmin() bool {
return u.Auth >= 4
}
// IsModerator returns true if the user has moderator privileges (auth >= 2)
func (u *User) IsModerator() bool {
return u.Auth >= 2
}
// IsFighting returns true if the user is currently fighting
func (u *User) IsFighting() bool {
return u.Fighting == 1
}
// IsAlive returns true if the user has HP > 0
func (u *User) IsAlive() bool {
return u.HP > 0
}
// GetSpellIDs returns spell IDs as a slice of strings
func (u *User) GetSpellIDs() []string {
if u.Spells == "" {
return []string{}
}
return strings.Split(u.Spells, ",")
}
// SetSpellIDs sets spell IDs from a slice of strings
func (u *User) SetSpellIDs(spells []string) {
u.Spells = strings.Join(spells, ",")
}
// HasSpell returns true if the user knows the specified spell ID
func (u *User) HasSpell(spellID string) bool {
spells := u.GetSpellIDs()
for _, spell := range spells {
if strings.TrimSpace(spell) == spellID {
return true
}
}
return false
}
// GetTownIDs returns town IDs as a slice of strings
func (u *User) GetTownIDs() []string {
if u.Towns == "" {
return []string{}
}
return strings.Split(u.Towns, ",")
}
// SetTownIDs sets town IDs from a slice of strings
func (u *User) SetTownIDs(towns []string) {
u.Towns = strings.Join(towns, ",")
}
// HasVisitedTown returns true if the user has visited the specified town ID
func (u *User) HasVisitedTown(townID string) bool {
towns := u.GetTownIDs()
for _, town := range towns {
if strings.TrimSpace(town) == townID {
return true
}
}
return false
}
// GetEquipment returns all equipped item information
func (u *User) GetEquipment() map[string]interface{} {
return map[string]interface{}{
"weapon": map[string]interface{}{"id": u.WeaponID, "name": u.WeaponName},
"armor": map[string]interface{}{"id": u.ArmorID, "name": u.ArmorName},
"shield": map[string]interface{}{"id": u.ShieldID, "name": u.ShieldName},
"slot1": map[string]interface{}{"id": u.Slot1ID, "name": u.Slot1Name},
"slot2": map[string]interface{}{"id": u.Slot2ID, "name": u.Slot2Name},
"slot3": map[string]interface{}{"id": u.Slot3ID, "name": u.Slot3Name},
}
}
// GetStats returns combat-relevant stats
func (u *User) GetStats() map[string]int {
return map[string]int{
"level": u.Level,
"hp": u.HP,
"mp": u.MP,
"tp": u.TP,
"max_hp": u.MaxHP,
"max_mp": u.MaxMP,
"max_tp": u.MaxTP,
"strength": u.Strength,
"dexterity": u.Dexterity,
"attack": u.Attack,
"defense": u.Defense,
"uber_damage": u.UberDamage,
"uber_defense": u.UberDefense,
}
}
// GetPosition returns the user's coordinates
func (u *User) GetPosition() (int, int) {
return u.X, u.Y
}
// SetPosition sets the user's coordinates
func (u *User) SetPosition(x, y int) {
u.X = x
u.Y = y
}

View File

@ -0,0 +1,670 @@
package users
import (
"os"
"testing"
"time"
"dk/internal/database"
)
func setupTestDB(t *testing.T) *database.DB {
testDB := "test_users.db"
t.Cleanup(func() {
os.Remove(testDB)
})
db, err := database.Open(testDB)
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
// Create users table
createTable := `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,
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,
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,
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 '',
drop_code INTEGER NOT NULL DEFAULT 0,
spells TEXT NOT NULL DEFAULT '',
towns TEXT NOT NULL DEFAULT ''
)`
if err := db.Exec(createTable); err != nil {
t.Fatalf("Failed to create users table: %v", err)
}
// Insert test data with specific timestamps
now := time.Now().Unix()
testUsers := `INSERT INTO users (username, password, email, verified, token, registered, last_online, auth,
x, y, class_id, level, gold, exp, hp, mp, tp, max_hp, max_mp, max_tp,
strength, dexterity, attack, defense, spells, towns) VALUES
('alice', 'hashed_pass_1', 'alice@example.com', 1, '', ?, ?, 0, 10, 20, 1, 5, 500, 1250, 25, 15, 12, 25, 15, 12, 8, 7, 10, 8, '1,2,5', '1,2'),
('bob', 'hashed_pass_2', 'bob@example.com', 1, '', ?, ?, 2, -5, 15, 2, 3, 300, 750, 20, 8, 10, 20, 8, 10, 6, 8, 8, 9, '3,4', '1'),
('charlie', 'hashed_pass_3', 'charlie@example.com', 0, 'verify_token_123', ?, ?, 4, 0, 0, 3, 1, 100, 0, 15, 0, 10, 15, 0, 10, 5, 5, 5, 5, '', ''),
('diana', 'hashed_pass_4', 'diana@example.com', 1, '', ?, ?, 0, 25, -10, 1, 8, 1200, 3500, 35, 25, 15, 35, 25, 15, 12, 10, 15, 12, '1,2,3,6,7', '1,2,3,4')`
timestamps := []interface{}{
now - 86400*7, now - 3600*2, // alice: registered 1 week ago, last online 2 hours ago
now - 86400*5, now - 86400*1, // bob: registered 5 days ago, last online 1 day ago
now - 86400*1, now - 86400*1, // charlie: registered 1 day ago, last online 1 day ago
now - 86400*30, now - 3600*1, // diana: registered 1 month ago, last online 1 hour ago
}
if err := db.Exec(testUsers, timestamps...); err != nil {
t.Fatalf("Failed to insert test users: %v", err)
}
return db
}
func TestFind(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test finding existing user
user, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find user: %v", err)
}
if user.ID != 1 {
t.Errorf("Expected ID 1, got %d", user.ID)
}
if user.Username != "alice" {
t.Errorf("Expected username 'alice', got '%s'", user.Username)
}
if user.Email != "alice@example.com" {
t.Errorf("Expected email 'alice@example.com', got '%s'", user.Email)
}
if user.Verified != 1 {
t.Errorf("Expected verified 1, got %d", user.Verified)
}
if user.Auth != 0 {
t.Errorf("Expected auth 0, got %d", user.Auth)
}
if user.Level != 5 {
t.Errorf("Expected level 5, got %d", user.Level)
}
// Test finding non-existent user
_, err = Find(db, 999)
if err == nil {
t.Error("Expected error when finding non-existent user")
}
}
func TestAll(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
users, err := All(db)
if err != nil {
t.Fatalf("Failed to get all users: %v", err)
}
if len(users) != 4 {
t.Errorf("Expected 4 users, got %d", len(users))
}
// Check ordering (by registered DESC)
if len(users) >= 2 {
if users[0].Registered < users[1].Registered {
t.Error("Expected users to be ordered by registration date (newest first)")
}
}
}
func TestByUsername(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test finding existing user by username
user, err := ByUsername(db, "alice")
if err != nil {
t.Fatalf("Failed to find user by username: %v", err)
}
if user.Username != "alice" {
t.Errorf("Expected username 'alice', got '%s'", user.Username)
}
if user.ID != 1 {
t.Errorf("Expected ID 1, got %d", user.ID)
}
// Test case insensitive search
userUpper, err := ByUsername(db, "ALICE")
if err != nil {
t.Fatalf("Failed to find user by uppercase username: %v", err)
}
if userUpper.ID != user.ID {
t.Error("Expected case insensitive search to return same user")
}
// Test non-existent user
_, err = ByUsername(db, "nonexistent")
if err == nil {
t.Error("Expected error when finding non-existent user by username")
}
}
func TestByEmail(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test finding existing user by email
user, err := ByEmail(db, "bob@example.com")
if err != nil {
t.Fatalf("Failed to find user by email: %v", err)
}
if user.Email != "bob@example.com" {
t.Errorf("Expected email 'bob@example.com', got '%s'", user.Email)
}
if user.Username != "bob" {
t.Errorf("Expected username 'bob', got '%s'", user.Username)
}
// Test non-existent email
_, err = ByEmail(db, "nonexistent@example.com")
if err == nil {
t.Error("Expected error when finding non-existent user by email")
}
}
func TestByLevel(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test users at level 1
level1Users, err := ByLevel(db, 1)
if err != nil {
t.Fatalf("Failed to get users by level: %v", err)
}
expectedCount := 1 // Charlie is level 1
if len(level1Users) != expectedCount {
t.Errorf("Expected %d users at level 1, got %d", expectedCount, len(level1Users))
}
// Verify all users are level 1
for _, user := range level1Users {
if user.Level != 1 {
t.Errorf("Expected level 1, got %d for user %s", user.Level, user.Username)
}
}
// Test level with no users
noUsers, err := ByLevel(db, 99)
if err != nil {
t.Fatalf("Failed to query non-existent level: %v", err)
}
if len(noUsers) != 0 {
t.Errorf("Expected 0 users at level 99, got %d", len(noUsers))
}
}
func TestOnline(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test users online within the last 6 hours
onlineUsers, err := Online(db, 6*time.Hour)
if err != nil {
t.Fatalf("Failed to get online users: %v", err)
}
// Alice (2 hours ago) and Diana (1 hour ago) should be included
expectedCount := 2
if len(onlineUsers) != expectedCount {
t.Errorf("Expected %d users online within 6 hours, got %d", expectedCount, len(onlineUsers))
}
// Check ordering (by last_online DESC)
if len(onlineUsers) >= 2 {
if onlineUsers[0].LastOnline < onlineUsers[1].LastOnline {
t.Error("Expected online users to be ordered by last online time")
}
}
// Test narrow time window
recentUsers, err := Online(db, 30*time.Minute)
if err != nil {
t.Fatalf("Failed to get recently online users: %v", err)
}
// No users should be online within the last 30 minutes
if len(recentUsers) != 0 {
t.Errorf("Expected 0 users online within 30 minutes, got %d", len(recentUsers))
}
}
func TestBuilder(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Create new user using builder
testTime := time.Now()
user, err := NewBuilder(db).
WithUsername("testuser").
WithPassword("hashed_password").
WithEmail("test@example.com").
WithVerified(true).
WithAuth(2).
WithClassID(2).
WithPosition(50, -25).
WithLevel(3).
WithGold(250).
WithStats(7, 6, 8, 7).
WithHP(20, 20).
WithMP(10, 10).
WithTP(12, 12).
WithCurrently("Exploring").
WithRegisteredTime(testTime).
WithSpells([]string{"1", "3", "5"}).
WithTowns([]string{"1", "2"}).
Create()
if err != nil {
t.Fatalf("Failed to create user with builder: %v", err)
}
if user.ID == 0 {
t.Error("Expected non-zero ID after creation")
}
if user.Username != "testuser" {
t.Errorf("Expected username 'testuser', got '%s'", user.Username)
}
if user.Email != "test@example.com" {
t.Errorf("Expected email 'test@example.com', got '%s'", user.Email)
}
if user.Verified != 1 {
t.Errorf("Expected verified 1, got %d", user.Verified)
}
if user.Auth != 2 {
t.Errorf("Expected auth 2, got %d", user.Auth)
}
if user.ClassID != 2 {
t.Errorf("Expected class_id 2, got %d", user.ClassID)
}
if user.X != 50 || user.Y != -25 {
t.Errorf("Expected position (50, -25), got (%d, %d)", user.X, user.Y)
}
if user.Level != 3 {
t.Errorf("Expected level 3, got %d", user.Level)
}
if user.Gold != 250 {
t.Errorf("Expected gold 250, got %d", user.Gold)
}
if user.Registered != testTime.Unix() {
t.Errorf("Expected registered time %d, got %d", testTime.Unix(), user.Registered)
}
// Verify it was saved to database
foundUser, err := Find(db, user.ID)
if err != nil {
t.Fatalf("Failed to find created user: %v", err)
}
if foundUser.Username != "testuser" {
t.Errorf("Created user not found in database")
}
// Test builder with defaults
defaultUser, err := NewBuilder(db).
WithUsername("defaultuser").
WithPassword("password").
WithEmail("default@example.com").
Create()
if err != nil {
t.Fatalf("Failed to create user with defaults: %v", err)
}
// Should have default values
if defaultUser.Level != 1 {
t.Errorf("Expected default level 1, got %d", defaultUser.Level)
}
if defaultUser.Gold != 100 {
t.Errorf("Expected default gold 100, got %d", defaultUser.Gold)
}
if defaultUser.HP != 15 {
t.Errorf("Expected default HP 15, got %d", defaultUser.HP)
}
// Test convenience methods
adminUser, err := NewBuilder(db).
WithUsername("admin").
WithPassword("admin_pass").
WithEmail("admin@example.com").
AsAdmin().
Create()
if err != nil {
t.Fatalf("Failed to create admin user: %v", err)
}
if adminUser.Auth != 4 {
t.Errorf("Expected admin auth 4, got %d", adminUser.Auth)
}
moderatorUser, err := NewBuilder(db).
WithUsername("mod").
WithPassword("mod_pass").
WithEmail("mod@example.com").
AsModerator().
Create()
if err != nil {
t.Fatalf("Failed to create moderator user: %v", err)
}
if moderatorUser.Auth != 2 {
t.Errorf("Expected moderator auth 2, got %d", moderatorUser.Auth)
}
}
func TestSave(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
user, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find user: %v", err)
}
// Modify user
user.Username = "alice_updated"
user.Email = "alice_updated@example.com"
user.Level = 10
user.Gold = 1000
user.UpdateLastOnline()
// Save changes
err = user.Save()
if err != nil {
t.Fatalf("Failed to save user: %v", err)
}
// Verify changes were saved
updatedUser, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find updated user: %v", err)
}
if updatedUser.Username != "alice_updated" {
t.Errorf("Expected updated username 'alice_updated', got '%s'", updatedUser.Username)
}
if updatedUser.Email != "alice_updated@example.com" {
t.Errorf("Expected updated email, got '%s'", updatedUser.Email)
}
if updatedUser.Level != 10 {
t.Errorf("Expected updated level 10, got %d", updatedUser.Level)
}
if updatedUser.Gold != 1000 {
t.Errorf("Expected updated gold 1000, got %d", updatedUser.Gold)
}
}
func TestDelete(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
user, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find user: %v", err)
}
// Delete user
err = user.Delete()
if err != nil {
t.Fatalf("Failed to delete user: %v", err)
}
// Verify user was deleted
_, err = Find(db, 1)
if err == nil {
t.Error("Expected error when finding deleted user")
}
}
func TestUserMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
alice, _ := Find(db, 1) // verified, auth 0
bob, _ := Find(db, 2) // verified, auth 2 (moderator)
charlie, _ := Find(db, 3) // unverified, auth 4 (admin)
// Test time methods
registeredTime := alice.RegisteredTime()
if registeredTime.IsZero() {
t.Error("Expected non-zero registered time")
}
lastOnlineTime := alice.LastOnlineTime()
if lastOnlineTime.IsZero() {
t.Error("Expected non-zero last online time")
}
// Test UpdateLastOnline
originalLastOnline := alice.LastOnline
alice.UpdateLastOnline()
if alice.LastOnline <= originalLastOnline {
t.Error("Expected last online to be updated to current time")
}
// Test verification status
if !alice.IsVerified() {
t.Error("Expected alice to be verified")
}
if charlie.IsVerified() {
t.Error("Expected charlie not to be verified")
}
// Test authorization levels
if alice.IsAdmin() {
t.Error("Expected alice not to be admin")
}
if alice.IsModerator() {
t.Error("Expected alice not to be moderator")
}
if !bob.IsModerator() {
t.Error("Expected bob to be moderator")
}
if !charlie.IsAdmin() {
t.Error("Expected charlie to be admin")
}
// Test combat status
if alice.IsFighting() {
t.Error("Expected alice not to be fighting")
}
if !alice.IsAlive() {
t.Error("Expected alice to be alive")
}
// Test position
x, y := alice.GetPosition()
if x != 10 || y != 20 {
t.Errorf("Expected position (10, 20), got (%d, %d)", x, y)
}
alice.SetPosition(30, 40)
x, y = alice.GetPosition()
if x != 30 || y != 40 {
t.Errorf("Expected updated position (30, 40), got (%d, %d)", x, y)
}
}
func TestSpellMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
alice, _ := Find(db, 1) // spells: "1,2,5"
// Test GetSpellIDs
spells := alice.GetSpellIDs()
expectedSpells := []string{"1", "2", "5"}
if len(spells) != len(expectedSpells) {
t.Errorf("Expected %d spells, got %d", len(expectedSpells), len(spells))
}
for i, expected := range expectedSpells {
if i < len(spells) && spells[i] != expected {
t.Errorf("Expected spell '%s' at position %d, got '%s'", expected, i, spells[i])
}
}
// Test HasSpell
if !alice.HasSpell("1") {
t.Error("Expected alice to have spell '1'")
}
if !alice.HasSpell("2") {
t.Error("Expected alice to have spell '2'")
}
if alice.HasSpell("3") {
t.Error("Expected alice not to have spell '3'")
}
// Test SetSpellIDs
newSpells := []string{"3", "4", "6", "7"}
alice.SetSpellIDs(newSpells)
if alice.Spells != "3,4,6,7" {
t.Errorf("Expected spells '3,4,6,7', got '%s'", alice.Spells)
}
// Test with empty spells
charlie, _ := Find(db, 3) // empty spells
emptySpells := charlie.GetSpellIDs()
if len(emptySpells) != 0 {
t.Errorf("Expected 0 spells for empty list, got %d", len(emptySpells))
}
}
func TestTownMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
alice, _ := Find(db, 1) // towns: "1,2"
// Test GetTownIDs
towns := alice.GetTownIDs()
expectedTowns := []string{"1", "2"}
if len(towns) != len(expectedTowns) {
t.Errorf("Expected %d towns, got %d", len(expectedTowns), len(towns))
}
for i, expected := range expectedTowns {
if i < len(towns) && towns[i] != expected {
t.Errorf("Expected town '%s' at position %d, got '%s'", expected, i, towns[i])
}
}
// Test HasVisitedTown
if !alice.HasVisitedTown("1") {
t.Error("Expected alice to have visited town '1'")
}
if !alice.HasVisitedTown("2") {
t.Error("Expected alice to have visited town '2'")
}
if alice.HasVisitedTown("3") {
t.Error("Expected alice not to have visited town '3'")
}
// Test SetTownIDs
newTowns := []string{"1", "2", "3", "4"}
alice.SetTownIDs(newTowns)
if alice.Towns != "1,2,3,4" {
t.Errorf("Expected towns '1,2,3,4', got '%s'", alice.Towns)
}
// Test with empty towns
charlie, _ := Find(db, 3) // empty towns
emptyTowns := charlie.GetTownIDs()
if len(emptyTowns) != 0 {
t.Errorf("Expected 0 towns for empty list, got %d", len(emptyTowns))
}
}
func TestGetEquipmentAndStats(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
alice, _ := Find(db, 1)
// Test GetEquipment
equipment := alice.GetEquipment()
if equipment == nil {
t.Error("Expected non-nil equipment map")
}
weapon, ok := equipment["weapon"].(map[string]interface{})
if !ok {
t.Error("Expected weapon to be a map")
}
if weapon["id"].(int) != alice.WeaponID {
t.Errorf("Expected weapon ID %d, got %v", alice.WeaponID, weapon["id"])
}
// Test GetStats
stats := alice.GetStats()
if stats == nil {
t.Error("Expected non-nil stats map")
}
if stats["level"] != alice.Level {
t.Errorf("Expected level %d, got %d", alice.Level, stats["level"])
}
if stats["hp"] != alice.HP {
t.Errorf("Expected HP %d, got %d", alice.HP, stats["hp"])
}
if stats["strength"] != alice.Strength {
t.Errorf("Expected strength %d, got %d", alice.Strength, stats["strength"])
}
}