improve database usage with models and change trackers, work on statbars

This commit is contained in:
Sky Johnson 2025-08-12 08:18:09 -05:00
parent 85af81a818
commit f0756c79b6
11 changed files with 423 additions and 275 deletions

View File

@ -112,6 +112,7 @@ div.title {
background-color: #eeeeee;
font-weight: bold;
padding: 5px;
margin-bottom: 0.1rem;
}
footer {
@ -263,4 +264,50 @@ button.img-button {
outline: none;
background: none;
cursor: pointer;
}
div#statbars {
display: flex;
justify-content: space-around;
margin: 1rem 0;
& > div.stat {
display: flex;
flex-direction: column;
align-items: center;
& > div.container {
display: flex;
align-items: flex-end;
background-color: rgba(0, 0, 0, 0.6);
width: 16px;
height: 160px;
border: 1px solid rgba(0, 0, 0, 0.6);
& > div.bar {
width: 100%;
height: 100%;
background-color: white;
}
&#hp > div.bar {
background: #56ab2f;
background: linear-gradient(to left, #a8e063, #56ab2f);
}
&#mp > div.bar {
background: #00c6ff;
background: linear-gradient(to right, #0072ff, #00c6ff);
}
&#tp > div.bar {
background: #757f9a;
background: linear-gradient(to right, #757f9a, #d7dde8);
}
}
& > label {
}
}
}

View File

@ -25,9 +25,9 @@ func (am *AuthManager) Authenticate(usernameOrEmail, plainPassword string) (*use
var user *users.User
var err error
user, err = users.GetByUsername(usernameOrEmail)
user, err = users.ByUsername(usernameOrEmail)
if err != nil {
user, err = users.GetByEmail(usernameOrEmail)
user, err = users.ByEmail(usernameOrEmail)
if err != nil {
return nil, err
}

122
internal/database/model.go Normal file
View File

@ -0,0 +1,122 @@
package database
import (
"fmt"
"reflect"
"strings"
"zombiezen.com/go/sqlite"
)
// Model interface for trackable database models
type Model interface {
GetTableName() string
GetID() int
SetID(id int)
GetDirtyFields() map[string]any
SetDirty(field string, value any)
ClearDirty()
IsDirty() bool
}
// BaseModel provides common model functionality
type BaseModel struct {
FieldTracker
}
// Set uses reflection to set a field and track changes
func Set(model Model, field string, value any) error {
v := reflect.ValueOf(model).Elem()
fieldVal := v.FieldByName(field)
if !fieldVal.IsValid() {
return fmt.Errorf("field %s does not exist", field)
}
if !fieldVal.CanSet() {
return fmt.Errorf("field %s cannot be set", field)
}
// Get current value for comparison
currentVal := fieldVal.Interface()
// Only set if value has changed
if !reflect.DeepEqual(currentVal, value) {
// Convert value to correct type
newVal := reflect.ValueOf(value)
if newVal.Type().ConvertibleTo(fieldVal.Type()) {
fieldVal.Set(newVal.Convert(fieldVal.Type()))
// Convert field name to snake_case for database
dbField := toSnakeCase(field)
model.SetDirty(dbField, value)
} else {
return fmt.Errorf("cannot convert %T to %s", value, fieldVal.Type())
}
}
return nil
}
// toSnakeCase converts CamelCase to snake_case
func toSnakeCase(s string) string {
var result strings.Builder
for i, r := range s {
if i > 0 && r >= 'A' && r <= 'Z' {
result.WriteByte('_')
}
if r >= 'A' && r <= 'Z' {
result.WriteRune(r - 'A' + 'a')
} else {
result.WriteRune(r)
}
}
return result.String()
}
// Save updates only dirty fields
func Save(model Model) error {
if model.GetID() == 0 {
return fmt.Errorf("cannot save model without ID")
}
return UpdateDirty(model)
}
// Insert creates a new record and sets the ID
func Insert(model Model, columns string, values ...any) error {
if model.GetID() != 0 {
return fmt.Errorf("model already has ID %d, use Save() to update", model.GetID())
}
return Transaction(func(tx *Tx) error {
placeholders := strings.Repeat("?,", len(values))
placeholders = placeholders[:len(placeholders)-1] // Remove trailing comma
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
model.GetTableName(), columns, placeholders)
if err := tx.Exec(query, values...); err != nil {
return fmt.Errorf("failed to insert: %w", err)
}
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)
}
model.SetID(id)
return nil
})
}
// Delete removes the record
func Delete(model Model) error {
if model.GetID() == 0 {
return fmt.Errorf("cannot delete model without ID")
}
return Exec("DELETE FROM ? WHERE id = ?", model.GetTableName(), model.GetID())
}

View File

@ -0,0 +1,82 @@
package database
import (
"fmt"
"strings"
)
// Trackable interface for models that can track field changes
type Trackable interface {
GetTableName() string
GetID() int
GetDirtyFields() map[string]any
SetDirty(field string, value any)
ClearDirty()
IsDirty() bool
}
// FieldTracker provides dirty field tracking functionality
type FieldTracker struct {
dirty map[string]any
}
// SetDirty marks a field as dirty with its new value
func (ft *FieldTracker) SetDirty(field string, value any) {
if ft.dirty == nil {
ft.dirty = make(map[string]any)
}
ft.dirty[field] = value
}
// GetDirtyFields returns map of dirty fields and their values
func (ft *FieldTracker) GetDirtyFields() map[string]any {
if ft.dirty == nil {
return make(map[string]any)
}
return ft.dirty
}
// ClearDirty clears all dirty field tracking
func (ft *FieldTracker) ClearDirty() {
ft.dirty = nil
}
// IsDirty returns true if any fields have been modified
func (ft *FieldTracker) IsDirty() bool {
return len(ft.dirty) > 0
}
// UpdateDirty updates only dirty fields in the database
func UpdateDirty(model Trackable) error {
if !model.IsDirty() {
return nil // No changes to save
}
dirty := model.GetDirtyFields()
if len(dirty) == 0 {
return nil
}
// Build dynamic UPDATE query
var setParts []string
var args []any
for field, value := range dirty {
setParts = append(setParts, field+" = ?")
args = append(args, value)
}
args = append(args, model.GetID()) // Add ID for WHERE clause
query := fmt.Sprintf("UPDATE %s SET %s WHERE id = ?",
model.GetTableName(),
strings.Join(setParts, ", "))
err := Exec(query, args...)
if err != nil {
return fmt.Errorf("failed to update %s: %w", model.GetTableName(), err)
}
model.ClearDirty()
return nil
}

10
internal/helpers/math.go Normal file
View File

@ -0,0 +1,10 @@
package helpers
// ClampPct divides two floats and clamps them to the given limits. Provides
// divide-by-zero protection by just returning 0.
func ClampPct(num, denom, minimum, maximum float64) float64 {
if denom == 0 {
return 0
}
return max(minimum, min(maximum, (num/denom)*100))
}

View File

@ -46,6 +46,10 @@ func RequireAuth(paths ...string) router.Middleware {
return
}
user := ctx.UserValue("user").(*users.User)
user.UpdateLastOnline()
user.Save()
next(ctx, params)
}
}
@ -105,4 +109,4 @@ func Logout(ctx router.Ctx, authManager *auth.AuthManager) {
ctx.SetUserValue("session", nil)
ctx.SetUserValue("user", nil)
}
}

View File

@ -143,7 +143,7 @@ func processRegister(ctx router.Ctx, _ []string) {
return
}
if _, err := users.GetByUsername(username); err == nil {
if _, err := users.ByUsername(username); err == nil {
auth.SetFlashMessage(ctx, "error", "Username already exists")
auth.SetFormData(ctx, map[string]string{
"username": username,
@ -153,7 +153,7 @@ func processRegister(ctx router.Ctx, _ []string) {
return
}
if _, err := users.GetByEmail(email); err == nil {
if _, err := users.ByEmail(email); err == nil {
auth.SetFlashMessage(ctx, "error", "Email already registered")
auth.SetFormData(ctx, map[string]string{
"username": username,

View File

@ -1,6 +1,7 @@
package components
import (
"dk/internal/helpers"
"dk/internal/middleware"
"dk/internal/router"
"dk/internal/template"
@ -61,31 +62,24 @@ func LeftAside(ctx router.Ctx) string {
// RightAside generates the right sidebar content
func RightAside(ctx router.Ctx) string {
if !middleware.IsAuthenticated(ctx) {
user := middleware.GetCurrentUser(ctx)
if user == nil {
return ""
}
// Load and render the rightside template with user data
rightSideTmpl, err := template.Cache.Load("rightside.html")
if err != nil {
return "" // Silently fail - sidebar is optional
}
// Get the current user from session
currentUser := middleware.GetCurrentUser(ctx)
if currentUser == nil {
return ""
}
user, err := users.Find(currentUser.ID)
tmpl, err := template.Cache.Load("rightside.html")
if err != nil {
return ""
}
// Pass the user object directly to the template
rightSideData := map[string]any{
"user": user.ToMap(),
}
hpPct := helpers.ClampPct(float64(user.HP), float64(user.MaxHP), 0, 100)
mpPct := helpers.ClampPct(float64(user.MP), float64(user.MaxMP), 0, 100)
tpPct := helpers.ClampPct(float64(user.TP), float64(user.MaxTP), 0, 100)
return rightSideTmpl.RenderNamed(rightSideData)
return tmpl.RenderNamed(map[string]any{
"user": user.ToMap(),
"hppct": hpPct,
"mppct": mpPct,
"tppct": tpPct,
})
}

View File

@ -3,7 +3,9 @@ package components
import (
"dk/internal/helpers/markdown"
"dk/internal/news"
"dk/internal/users"
"fmt"
"time"
)
func GenerateTownNews() string {
@ -22,5 +24,19 @@ func GenerateTownNews() string {
func GenerateTownWhosOnline() string {
title := `<div class="title">Who's Online</div>`
onlineUsers, err := users.Online(10 * time.Minute)
if err == nil && len(onlineUsers) > 0 {
if len(onlineUsers) == 1 {
title += "<p>There is 1 user online in the last 10 minutes:</p>"
} else {
title += fmt.Sprintf("<p>There are %d users online in the last 10 minutes:</p>", len(onlineUsers))
}
for _, user := range onlineUsers {
title += fmt.Sprintf(`<div>%s</div>`, user.Username)
}
return title
}
return title + "<div>No one!</div>"
}

View File

@ -13,6 +13,8 @@ import (
// User represents a user in the database
type User struct {
database.BaseModel
ID int `db:"id" json:"id"`
Username string `db:"username" json:"username"`
Password string `db:"password" json:"password"`
@ -65,296 +67,221 @@ type User struct {
Towns string `db:"towns" json:"towns"`
}
// Implement Model interface
func (u *User) GetTableName() string {
return "users"
}
func (u *User) GetID() int {
return u.ID
}
func (u *User) SetID(id int) {
u.ID = id
}
// Convenience methods wrapping generic functions
func (u *User) Set(field string, value any) error {
return database.Set(u, field, value)
}
func (u *User) Save() error {
return database.Save(u)
}
func (u *User) Delete() error {
return database.Delete(u)
}
// New creates a new User with sensible defaults
func New() *User {
now := time.Now().Unix()
return &User{
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
Verified: 0,
Token: "",
Registered: now,
LastOnline: now,
Auth: 0,
X: 0,
Y: 0,
ClassID: 1,
Currently: "In Town",
Fighting: 0,
HP: 15,
MP: 0,
TP: 10,
MaxHP: 15,
MaxMP: 0,
MaxTP: 10,
Level: 1,
Gold: 100,
Exp: 0,
Strength: 5,
Dexterity: 5,
Attack: 5,
Defense: 5,
Spells: "",
Towns: "",
}
}
var userScanner = scanner.New[User]()
// userColumns returns the column list for user queries
func userColumns() string {
return userScanner.Columns()
}
// scanUser populates a User struct using the fast scanner
func scanUser(stmt *sqlite.Stmt) *User {
user := &User{}
userScanner.Scan(stmt, user)
return user
}
// Find retrieves a user by ID
// Query functions
func Find(id int) (*User, error) {
var user *User
query := `SELECT ` + userColumns() + ` FROM users WHERE id = ?`
err := database.Query(query, func(stmt *sqlite.Stmt) error {
user = scanUser(stmt)
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() ([]*User, error) {
var users []*User
query := `SELECT ` + userColumns() + ` FROM users ORDER BY registered DESC, id DESC`
err := database.Query(query, func(stmt *sqlite.Stmt) error {
user := scanUser(stmt)
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(username string) (*User, error) {
var user *User
query := `SELECT ` + userColumns() + ` FROM users WHERE LOWER(username) = LOWER(?) LIMIT 1`
err := database.Query(query, func(stmt *sqlite.Stmt) error {
user = scanUser(stmt)
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(email string) (*User, error) {
var user *User
query := `SELECT ` + userColumns() + ` FROM users WHERE email = ? LIMIT 1`
err := database.Query(query, func(stmt *sqlite.Stmt) error {
user = scanUser(stmt)
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(level int) ([]*User, error) {
var users []*User
query := `SELECT ` + userColumns() + ` FROM users WHERE level = ? ORDER BY exp DESC, id ASC`
err := database.Query(query, func(stmt *sqlite.Stmt) error {
user := scanUser(stmt)
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(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 := database.Query(query, func(stmt *sqlite.Stmt) error {
user := scanUser(stmt)
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")
}
func (u *User) Insert() error {
columns := `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`
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 database.Exec(query, u.Username, u.Password, u.Email, u.Verified, u.Token,
values := []any{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)
u.Slot1Name, u.Slot2Name, u.Slot3Name, u.DropCode, u.Spells, u.Towns}
return database.Insert(u, columns, values...)
}
// Insert saves a new user to the database and sets the ID
func (u *User) Insert() error {
if u.ID != 0 {
return fmt.Errorf("user already has ID %d, use Save() to update", u.ID)
}
// Use a transaction to ensure we can get the ID
err := database.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, 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); 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)
}
u.ID = id
return nil
})
return err
}
// Delete removes the user from the database
func (u *User) Delete() error {
if u.ID == 0 {
return fmt.Errorf("cannot delete user without ID")
}
return database.Exec("DELETE FROM users WHERE id = ?", u.ID)
}
// RegisteredTime returns the registration timestamp as a time.Time
// Helper methods
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()
u.Set("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{}
@ -362,12 +289,10 @@ func (u *User) GetSpellIDs() []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, ",")
u.Set("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 {
@ -378,7 +303,6 @@ func (u *User) HasSpell(spellID string) bool {
return false
}
// GetTownIDs returns town IDs as a slice of strings
func (u *User) GetTownIDs() []string {
if u.Towns == "" {
return []string{}
@ -386,12 +310,10 @@ func (u *User) GetTownIDs() []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, ",")
u.Set("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 {
@ -402,7 +324,6 @@ func (u *User) HasVisitedTown(townID string) bool {
return false
}
// GetEquipment returns all equipped item information
func (u *User) GetEquipment() map[string]any {
return map[string]any{
"weapon": map[string]any{"id": u.WeaponID, "name": u.WeaponName},
@ -414,7 +335,6 @@ func (u *User) GetEquipment() map[string]any {
}
}
// GetStats returns combat-relevant stats
func (u *User) GetStats() map[string]int {
return map[string]int{
"level": u.Level,
@ -433,59 +353,13 @@ func (u *User) GetStats() map[string]int {
}
}
// 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
}
// GetByUsername retrieves a user by username
func GetByUsername(username string) (*User, error) {
var user *User
query := `SELECT ` + userColumns() + ` FROM users WHERE LOWER(username) = LOWER(?) LIMIT 1`
err := database.Query(query, func(stmt *sqlite.Stmt) error {
user = scanUser(stmt)
return nil
}, username)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
if user == nil {
return nil, fmt.Errorf("user not found: %s", username)
}
return user, nil
}
// GetByEmail retrieves a user by email
func GetByEmail(email string) (*User, error) {
var user *User
query := `SELECT ` + userColumns() + ` FROM users WHERE LOWER(email) = LOWER(?) LIMIT 1`
err := database.Query(query, func(stmt *sqlite.Stmt) error {
user = scanUser(stmt)
return nil
}, email)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
if user == nil {
return nil, fmt.Errorf("user not found: %s", email)
}
return user, nil
u.Set("X", x)
u.Set("Y", y)
}
// ToMap converts the user to a map for efficient template rendering

View File

@ -1,58 +1,57 @@
<table width="100%">
<tr>
<td class="title"><img src="/assets/images/button_character.gif" alt="Character" title="Character"></td>
</tr>
<tr>
<td>
<b>{user.Username}</b><br>
Level: {user.Level}<br>
Exp: {user.Exp}<br>
Gold: {user.Gold}<br>
HP: {user.HP}<br>
MP: {user.MP}<br>
TP: {user.TP}<br>
{statbars}<br>
<a href="javascript:opencharpopup()">Extended Stats</a>
</td>
</tr>
</table>
<section>
<div class="title"><img src="/assets/images/button_character.gif" alt="Character" title="Character"></div>
<ul class="unstyled">
<li><b>{user.Username}</b></li>
<li>Level: {user.Level}</li>
<li>Exp: {user.Exp}</li>
<li>Gold: {user.Gold}</li>
<li>HP: {user.HP}</li>
<li>MP: {user.MP}</li>
<li>TP: {user.TP}</li>
</ul>
<div id="statbars">
<div class="stat">
<div id="hp" class="container"><div class="bar" style="height: {hppct}%"></div></div>
<label>HP</label>
</div>
<div class="stat">
<div id="mp" class="container"><div class="bar" style="height: {mppct}%"></div></div>
<label>MP</label>
</div>
<div class="stat">
<div id="tp" class="container"><div class="bar" style="height: {tppct}%"></div></div>
<label>TP</label>
</div>
</div>
<a href="javascript:open_char_popup()">Extended Stats</a>
</section>
<br>
<table width="100%">
<tr>
<td class="title"><img src="/assets/images/button_inventory.gif" alt="Inventory" title="Inventory"></td>
</tr>
<tr>
<td>
<table width="100%">
<tr>
<td><img src="/assets/images/icon_weapon.gif" alt="Weapon" title="Weapon"></td>
<td width="100%">{weaponname}</td>
</tr>
<tr>
<td><img src="/assets/images/icon_armor.gif" alt="Armor" title="Armor"></td>
<td width="100%">{armorname}</td>
</tr>
<tr>
<td><img src="/assets/images/icon_shield.gif" alt="Shield" title="Shield"></td>
<td width="100%">{shieldname}</td>
</tr>
</table>
{slot1name}<br>
{slot2name}<br>
{slot3name}
</td>
</tr>
</table>
<section>
<div class="title"><img src="/assets/images/button_inventory.gif" alt="Inventory" title="Inventory"></div>
<div>
<img src="/assets/images/icon_weapon.gif" alt="Weapon" title="Weapon">
{weaponname}
</div>
<div>
<img src="/assets/images/icon_armor.gif" alt="Armor" title="Armor">
{armorname}
</div>
<div>
<img src="/assets/images/icon_shield.gif" alt="Shield" title="Shield">
{shieldname}
</div>
{slot1name}
{slot2name}
{slot3name}
</section>
<br>
<table width="100%">
<tr>
<td class="title"><img src="/assets/images/button_fastspells.gif" alt="Fast Spells" title="Fast Spells"></td>
</tr>
<tr>
<td>{magiclist}</td>
</tr>
</table>
<section>
<div class="title"><img src="/assets/images/button_fastspells.gif" alt="Fast Spells" title="Fast Spells"></div>
{magiclist}
</section>