Files
Erupe/server/channelserver/repo_character.go
Houmgaor ecfe58ffb4 feat: add SQLite support, setup wizard enhancements, and live dashboard
Add zero-dependency SQLite mode so users can run Erupe without
PostgreSQL. A transparent db.DB wrapper auto-translates PostgreSQL
SQL ($N placeholders, now(), ::casts, ILIKE, public. prefix,
TRUNCATE) for SQLite at runtime — all 28 repo files use the wrapper
with no per-query changes needed.

Setup wizard gains two new steps: quest file detection with download
link, and gameplay presets (solo/small/community/rebalanced). The API
server gets a /dashboard endpoint with auto-refreshing stats.

CI release workflow now builds and pushes Docker images to GHCR
alongside binary artifacts on tag push.

Key changes:
- common/db: DB/Tx wrapper with 6 SQL translation rules
- server/migrations/sqlite: full SQLite schema (0001-0005)
- config: Database.Driver field ("postgres" or "sqlite")
- main.go: SQLite connection with WAL mode, single writer
- server/setup: quest check + preset selection steps
- server/api: /dashboard with live stats
- .github/workflows: Docker in release, deduplicate docker.yml
2026-03-05 18:00:30 +01:00

246 lines
9.6 KiB
Go

package channelserver
import (
"database/sql"
"time"
dbutil "erupe-ce/common/db"
)
// CharacterRepository centralizes all database access for the characters table.
type CharacterRepository struct {
db *dbutil.DB
}
// NewCharacterRepository creates a new CharacterRepository.
func NewCharacterRepository(db *dbutil.DB) *CharacterRepository {
return &CharacterRepository{db: db}
}
// LoadColumn reads a single []byte column by character ID.
func (r *CharacterRepository) LoadColumn(charID uint32, column string) ([]byte, error) {
var data []byte
err := r.db.QueryRow("SELECT "+column+" FROM characters WHERE id = $1", charID).Scan(&data)
return data, err
}
// SaveColumn writes a single []byte column by character ID.
func (r *CharacterRepository) SaveColumn(charID uint32, column string, data []byte) error {
_, err := r.db.Exec("UPDATE characters SET "+column+"=$1 WHERE id=$2", data, charID)
return err
}
// ReadInt reads a single integer column (0 for NULL) by character ID.
func (r *CharacterRepository) ReadInt(charID uint32, column string) (int, error) {
var value int
err := r.db.QueryRow("SELECT COALESCE("+column+", 0) FROM characters WHERE id=$1", charID).Scan(&value)
return value, err
}
// AdjustInt atomically adds delta to an integer column and returns the new value.
func (r *CharacterRepository) AdjustInt(charID uint32, column string, delta int) (int, error) {
var value int
err := r.db.QueryRow(
"UPDATE characters SET "+column+"=COALESCE("+column+", 0)+$1 WHERE id=$2 RETURNING "+column,
delta, charID,
).Scan(&value)
return value, err
}
// GetName returns the character name by ID.
func (r *CharacterRepository) GetName(charID uint32) (string, error) {
var name string
err := r.db.QueryRow("SELECT name FROM characters WHERE id=$1", charID).Scan(&name)
return name, err
}
// GetUserID returns the owning user_id for a character.
func (r *CharacterRepository) GetUserID(charID uint32) (uint32, error) {
var userID uint32
err := r.db.QueryRow("SELECT user_id FROM characters WHERE id=$1", charID).Scan(&userID)
return userID, err
}
// UpdateLastLogin sets the last_login timestamp.
func (r *CharacterRepository) UpdateLastLogin(charID uint32, timestamp int64) error {
_, err := r.db.Exec("UPDATE characters SET last_login=$1 WHERE id=$2", timestamp, charID)
return err
}
// UpdateTimePlayed sets the time_played value.
func (r *CharacterRepository) UpdateTimePlayed(charID uint32, timePlayed int) error {
_, err := r.db.Exec("UPDATE characters SET time_played=$1 WHERE id=$2", timePlayed, charID)
return err
}
// GetCharIDsByUserID returns all character IDs belonging to a user.
func (r *CharacterRepository) GetCharIDsByUserID(userID uint32) ([]uint32, error) {
var ids []uint32
err := r.db.Select(&ids, "SELECT id FROM characters WHERE user_id=$1", userID)
return ids, err
}
// ReadTime reads a single time.Time column by character ID.
// Returns the provided default if the column is NULL.
func (r *CharacterRepository) ReadTime(charID uint32, column string, defaultVal time.Time) (time.Time, error) {
var t sql.NullTime
err := r.db.QueryRow("SELECT "+column+" FROM characters WHERE id=$1", charID).Scan(&t)
if err != nil {
return defaultVal, err
}
if !t.Valid {
return defaultVal, nil
}
return t.Time, nil
}
// SaveTime writes a single time.Time column by character ID.
func (r *CharacterRepository) SaveTime(charID uint32, column string, value time.Time) error {
_, err := r.db.Exec("UPDATE characters SET "+column+"=$1 WHERE id=$2", value, charID)
return err
}
// SaveInt writes a single integer column by character ID.
func (r *CharacterRepository) SaveInt(charID uint32, column string, value int) error {
_, err := r.db.Exec("UPDATE characters SET "+column+"=$1 WHERE id=$2", value, charID)
return err
}
// SaveBool writes a single boolean column by character ID.
func (r *CharacterRepository) SaveBool(charID uint32, column string, value bool) error {
_, err := r.db.Exec("UPDATE characters SET "+column+"=$1 WHERE id=$2", value, charID)
return err
}
// SaveString writes a single string column by character ID.
func (r *CharacterRepository) SaveString(charID uint32, column string, value string) error {
_, err := r.db.Exec("UPDATE characters SET "+column+"=$1 WHERE id=$2", value, charID)
return err
}
// ReadBool reads a single boolean column by character ID.
func (r *CharacterRepository) ReadBool(charID uint32, column string) (bool, error) {
var value bool
err := r.db.QueryRow("SELECT "+column+" FROM characters WHERE id=$1", charID).Scan(&value)
return value, err
}
// ReadString reads a single string column by character ID (empty string for NULL).
func (r *CharacterRepository) ReadString(charID uint32, column string) (string, error) {
var value sql.NullString
err := r.db.QueryRow("SELECT "+column+" FROM characters WHERE id=$1", charID).Scan(&value)
if err != nil {
return "", err
}
return value.String, nil
}
// LoadColumnWithDefault reads a []byte column, returning defaultVal if NULL.
func (r *CharacterRepository) LoadColumnWithDefault(charID uint32, column string, defaultVal []byte) ([]byte, error) {
var data []byte
err := r.db.QueryRow("SELECT "+column+" FROM characters WHERE id=$1", charID).Scan(&data)
if err != nil {
return defaultVal, err
}
if data == nil {
return defaultVal, nil
}
return data, nil
}
// SetDeleted marks a character as deleted.
func (r *CharacterRepository) SetDeleted(charID uint32) error {
_, err := r.db.Exec("UPDATE characters SET deleted=true WHERE id=$1", charID)
return err
}
// UpdateDailyCafe sets daily_time, bonus_quests, and daily_quests atomically.
func (r *CharacterRepository) UpdateDailyCafe(charID uint32, dailyTime time.Time, bonusQuests, dailyQuests uint32) error {
_, err := r.db.Exec("UPDATE characters SET daily_time=$1, bonus_quests=$2, daily_quests=$3 WHERE id=$4",
dailyTime, bonusQuests, dailyQuests, charID)
return err
}
// ResetDailyQuests zeroes bonus_quests and daily_quests.
func (r *CharacterRepository) ResetDailyQuests(charID uint32) error {
_, err := r.db.Exec("UPDATE characters SET bonus_quests=0, daily_quests=0 WHERE id=$1", charID)
return err
}
// ReadEtcPoints reads bonus_quests, daily_quests, and promo_points.
func (r *CharacterRepository) ReadEtcPoints(charID uint32) (bonusQuests, dailyQuests, promoPoints uint32, err error) {
err = r.db.QueryRow("SELECT bonus_quests, daily_quests, promo_points FROM characters WHERE id=$1", charID).
Scan(&bonusQuests, &dailyQuests, &promoPoints)
return
}
// ResetCafeTime zeroes cafe_time and sets cafe_reset.
func (r *CharacterRepository) ResetCafeTime(charID uint32, cafeReset time.Time) error {
_, err := r.db.Exec("UPDATE characters SET cafe_time=0, cafe_reset=$1 WHERE id=$2", cafeReset, charID)
return err
}
// UpdateGuildPostChecked sets guild_post_checked to now().
func (r *CharacterRepository) UpdateGuildPostChecked(charID uint32) error {
_, err := r.db.Exec("UPDATE characters SET guild_post_checked=now() WHERE id=$1", charID)
return err
}
// ReadGuildPostChecked reads guild_post_checked timestamp.
func (r *CharacterRepository) ReadGuildPostChecked(charID uint32) (time.Time, error) {
var t time.Time
err := r.db.QueryRow("SELECT guild_post_checked FROM characters WHERE id=$1", charID).Scan(&t)
return t, err
}
// SaveMercenary updates savemercenary and optionally rasta_id.
// When rastaID is 0, only the mercenary blob is saved — the existing rasta_id
// (typically NULL for characters without a mercenary) is preserved. Writing 0
// would pollute GetMercenaryLoans queries that match on pact_id.
func (r *CharacterRepository) SaveMercenary(charID uint32, data []byte, rastaID uint32) error {
if rastaID == 0 {
_, err := r.db.Exec("UPDATE characters SET savemercenary=$1 WHERE id=$2", data, charID)
return err
}
_, err := r.db.Exec("UPDATE characters SET savemercenary=$1, rasta_id=$2 WHERE id=$3", data, rastaID, charID)
return err
}
// UpdateGCPAndPact updates gcp and pact_id atomically.
func (r *CharacterRepository) UpdateGCPAndPact(charID uint32, gcp uint32, pactID uint32) error {
_, err := r.db.Exec("UPDATE characters SET gcp=$1, pact_id=$2 WHERE id=$3", gcp, pactID, charID)
return err
}
// FindByRastaID looks up name and id by rasta_id.
func (r *CharacterRepository) FindByRastaID(rastaID int) (charID uint32, name string, err error) {
err = r.db.QueryRow("SELECT name, id FROM characters WHERE rasta_id=$1", rastaID).Scan(&name, &charID)
return
}
// SaveCharacterData updates the core save fields on a character.
func (r *CharacterRepository) SaveCharacterData(charID uint32, compSave []byte, hr, gr uint16, isFemale bool, weaponType uint8, weaponID uint16) error {
_, err := r.db.Exec(`UPDATE characters SET savedata=$1, is_new_character=false, hr=$2, gr=$3, is_female=$4, weapon_type=$5, weapon_id=$6 WHERE id=$7`,
compSave, hr, gr, isFemale, weaponType, weaponID, charID)
return err
}
// SaveHouseData updates house-related fields in user_binary.
func (r *CharacterRepository) SaveHouseData(charID uint32, houseTier []byte, houseData, bookshelf, gallery, tore, garden []byte) error {
_, err := r.db.Exec(`UPDATE user_binary SET house_tier=$1, house_data=$2, bookshelf=$3, gallery=$4, tore=$5, garden=$6 WHERE id=$7`,
houseTier, houseData, bookshelf, gallery, tore, garden, charID)
return err
}
// LoadSaveData reads the core save columns for a character.
// Returns charID, savedata, isNewCharacter, name, and any error.
func (r *CharacterRepository) LoadSaveData(charID uint32) (uint32, []byte, bool, string, error) {
var id uint32
var savedata []byte
var isNew bool
var name string
err := r.db.QueryRow("SELECT id, savedata, is_new_character, name FROM characters WHERE id = $1", charID).
Scan(&id, &savedata, &isNew, &name)
return id, savedata, isNew, name, err
}