feat: leveling system

This commit is contained in:
2026-03-17 15:17:36 +00:00
parent 0354fdc032
commit 2bce787c05
24 changed files with 1840 additions and 3 deletions
+26
View File
@@ -0,0 +1,26 @@
package db
import (
"database/sql"
"time"
"velox-bot/internal/config"
_ "github.com/jackc/pgx/v5/stdlib"
)
func NewDB(config *config.Config) (*sql.DB, error) {
pool, err := sql.Open("pgx", config.DBHost)
if err != nil {
return nil, err
}
pool.SetMaxIdleConns(10)
pool.SetMaxOpenConns(100)
pool.SetConnMaxLifetime(time.Hour)
if err := pool.Ping(); err != nil {
return nil, err
}
return pool, nil
}
+94
View File
@@ -0,0 +1,94 @@
package levelrepo
import (
"context"
"database/sql"
)
type Level struct {
GuildID int64
UserID int64
Level int
XP int64
}
type Repo struct {
db *sql.DB
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
func (r *Repo) GetLevel(ctx context.Context, guildID, userID int64) (*Level, error) {
const q = `
SELECT level, xp
FROM levels
WHERE guild = $1 AND user_id = $2
`
row := r.db.QueryRowContext(ctx, q, guildID, userID)
var lvl Level
lvl.GuildID = guildID
lvl.UserID = userID
switch err := row.Scan(&lvl.Level, &lvl.XP); err {
case sql.ErrNoRows:
// default new user
lvl.Level = 0
lvl.XP = 0
return &lvl, nil
case nil:
return &lvl, nil
default:
return nil, err
}
}
func (r *Repo) UpsertLevel(ctx context.Context, lvl *Level) error {
const q = `
INSERT INTO levels (guild, user_id, level, xp)
VALUES ($1, $2, $3, $4)
ON CONFLICT (guild, user_id)
DO UPDATE SET level = EXCLUDED.level, xp = EXCLUDED.xp
`
_, err := r.db.ExecContext(ctx, q, lvl.GuildID, lvl.UserID, lvl.Level, lvl.XP)
return err
}
func (r *Repo) SetLevel(ctx context.Context, guildID, userID int64, level int) error {
const q = `
INSERT INTO levels (guild, user_id, level, xp)
VALUES ($1, $2, $3, 99)
ON CONFLICT (guild, user_id)
DO UPDATE SET level = EXCLUDED.level, xp = 99
`
_, err := r.db.ExecContext(ctx, q, guildID, userID, level)
return err
}
func (r *Repo) TopLevels(ctx context.Context, guildID int64, limit int) ([]*Level, error) {
const q = `
SELECT user_id, level, xp
FROM levels
WHERE guild = $1
ORDER BY level DESC, xp DESC, user_id ASC
LIMIT $2
`
rows, err := r.db.QueryContext(ctx, q, guildID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]*Level, 0, limit)
for rows.Next() {
lvl := &Level{GuildID: guildID}
if err := rows.Scan(&lvl.UserID, &lvl.Level, &lvl.XP); err != nil {
return nil, err
}
out = append(out, lvl)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
+138
View File
@@ -0,0 +1,138 @@
package settingsrepo
import (
"context"
"database/sql"
)
type Repo struct {
db *sql.DB
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
func (r *Repo) SetLevelSystemEnabled(ctx context.Context, guildID int64, enabled bool) error {
const q = `
INSERT INTO levelsettings (guild_id, levelsys)
VALUES ($1, $2)
ON CONFLICT (guild_id)
DO UPDATE SET levelsys = EXCLUDED.levelsys
`
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
return err
}
func (r *Repo) IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error) {
const q = `
SELECT levelsys
FROM levelsettings
WHERE guild_id = $1
`
row := r.db.QueryRowContext(ctx, q, guildID)
var enabled bool
switch err := row.Scan(&enabled); err {
case nil:
return enabled, nil
case sql.ErrNoRows:
return false, nil
default:
return false, err
}
}
func (r *Repo) GetLevelupChannelID(ctx context.Context, guildID int64) (int64, bool, error) {
const q = `
SELECT levelup_channel_id
FROM levelup
WHERE guild_id = $1
`
row := r.db.QueryRowContext(ctx, q, guildID)
var channelID sql.NullInt64
switch err := row.Scan(&channelID); err {
case nil:
if !channelID.Valid {
return 0, false, nil
}
return channelID.Int64, true, nil
case sql.ErrNoRows:
return 0, false, nil
default:
return 0, false, err
}
}
func (r *Repo) GetRoleRewardForLevel(ctx context.Context, guildID int64, level int) (roleID int64, ok bool, err error) {
const q = `
SELECT role
FROM levelrewards
WHERE guild_id = $1 AND levelreq = $2
`
row := r.db.QueryRowContext(ctx, q, guildID, level)
var role sql.NullInt64
switch err := row.Scan(&role); err {
case nil:
if !role.Valid {
return 0, false, nil
}
return role.Int64, true, nil
case sql.ErrNoRows:
return 0, false, nil
default:
return 0, false, err
}
}
func (r *Repo) SetRoleRewardForLevel(ctx context.Context, guildID int64, level int, roleID int64) error {
const q = `
INSERT INTO levelrewards (guild_id, levelreq, role)
VALUES ($1, $2, $3)
ON CONFLICT (guild_id, levelreq)
DO UPDATE SET role = EXCLUDED.role
`
_, err := r.db.ExecContext(ctx, q, guildID, level, roleID)
return err
}
func (r *Repo) SetLevelUpChannel(ctx context.Context, guildID int64, channelID int64) error {
const q = `
INSERT INTO levelup (guild_id, levelup_channel_id)
VALUES ($1, $2)
ON CONFLICT (guild_id)
DO UPDATE SET levelup_channel_id = EXCLUDED.levelup_channel_id
`
_, err := r.db.ExecContext(ctx, q, guildID, channelID)
return err
}
func (r *Repo) SetLevelUpMessage(ctx context.Context, guildID int64, message string) error {
const q = `
UPDATE levelsettings
SET message = $1
WHERE guild_id = $2
`
_, err := r.db.ExecContext(ctx, q, message, guildID)
return err
}
func (r *Repo) GetLevelUpMessage(ctx context.Context, guildID int64) (string, error) {
const q = `
SELECT message
FROM levelsettings
WHERE guild_id = $1
`
row := r.db.QueryRowContext(ctx, q, guildID)
var message sql.NullString
switch err := row.Scan(&message); err {
case nil:
if !message.Valid {
return "GG {user}, you reached level {level}!", nil
}
return message.String, nil
case sql.ErrNoRows:
return "GG {user}, you reached level {level}!", nil
default:
return "", err
}
}
+134
View File
@@ -0,0 +1,134 @@
package level
import (
"context"
"math/rand"
"time"
"velox-bot/internal/db/repos/levelrepo"
)
type SettingsProvider interface {
IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error)
GetLevelupChannelID(ctx context.Context, guildID int64) (int64, bool, error)
GetRoleRewardForLevel(ctx context.Context, guildID int64, level int) (roleID int64, ok bool, err error)
SetLevelUpChannel(ctx context.Context, guildID int64, channelID int64) error
SetLevelUpMessage(ctx context.Context, guildID int64, message string) error
GetLevelUpMessage(ctx context.Context, guildID int64) (string, error)
}
type Result struct {
LeveledUp bool
NewLevel int
RoleRewardID int64 // 0 if none
LevelupMessage string // you can fill this later
LevelupChannel int64 // 0 means "use current channel"
UpdatedXP int64
UpdatedLevel int
}
type Service struct {
levels *levelrepo.Repo
settings SettingsProvider
rand *rand.Rand
}
func New(levels *levelrepo.Repo, settings SettingsProvider) *Service {
return &Service{
levels: levels,
settings: settings,
rand: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
// HandleMessage mirrors your Python gain_xp behavior, minus Discord specifics.
func (s *Service) HandleMessage(ctx context.Context, guildID, userID int64) (*Result, error) {
enabled, err := s.settings.IsLevelSystemEnabled(ctx, guildID)
if err != nil || !enabled {
return &Result{LeveledUp: false}, err
}
lvl, err := s.levels.GetLevel(ctx, guildID, userID)
if err != nil {
return nil, err
}
// XP gain rules
shouldGainXP := false
if lvl.Level < 5 {
shouldGainXP = true
} else {
// random.randint(1, level//4) == 1
div := lvl.Level / 4
if div < 1 {
div = 1
}
if s.rand.Intn(div)+1 == 1 {
shouldGainXP = true
}
}
if !shouldGainXP {
return &Result{
LeveledUp: false,
UpdatedXP: lvl.XP,
UpdatedLevel: lvl.Level,
}, nil
}
// This assumes your setXp() adds some fixed XP per hit; adapt amount as needed.
xpPerMessage := s.rand.Intn(3) + 1
lvl.XP += int64(xpPerMessage)
res := &Result{
LeveledUp: false,
UpdatedXP: lvl.XP,
UpdatedLevel: lvl.Level,
}
// Level-up rule: if xp >= 100 -> level++
if lvl.XP >= 100 {
lvl.Level++
lvl.XP = 0 // or keep overflow, depending on Python behavior
res.LeveledUp = true
res.NewLevel = lvl.Level
res.UpdatedXP = lvl.XP
res.UpdatedLevel = lvl.Level
roleID, ok, err := s.settings.GetRoleRewardForLevel(ctx, guildID, lvl.Level)
if err != nil {
return nil, err
}
if ok {
res.RoleRewardID = roleID
}
chID, ok, err := s.settings.GetLevelupChannelID(ctx, guildID)
if err != nil {
return nil, err
}
if ok {
res.LevelupChannel = chID
}
}
// Persist
if err := s.levels.UpsertLevel(ctx, lvl); err != nil {
return nil, err
}
return res, nil
}
func (s *Service) SetLevel(ctx context.Context, guildID, userID int64, level int) error {
return s.levels.SetLevel(ctx, guildID, userID, level)
}
func (s *Service) GetLevel(ctx context.Context, guildID, userID int64) (*levelrepo.Level, error) {
return s.levels.GetLevel(ctx, guildID, userID)
}
func (s *Service) TopLevels(ctx context.Context, guildID int64, limit int) ([]*levelrepo.Level, error) {
return s.levels.TopLevels(ctx, guildID, limit)
}
@@ -0,0 +1,49 @@
package levelsettings
import (
"context"
"velox-bot/internal/db/repos/settingsrepo"
)
type Service struct {
settings *settingsrepo.Repo
}
func New(settings *settingsrepo.Repo) *Service {
return &Service{settings: settings}
}
func (s *Service) ToggleLevelSystem(ctx context.Context, guildID int64) (bool, error) {
enabled, err := s.settings.IsLevelSystemEnabled(ctx, guildID)
if err != nil {
return false, err
}
newEnabled := !enabled
if err := s.settings.SetLevelSystemEnabled(ctx, guildID, newEnabled); err != nil {
return false, err
}
return newEnabled, nil
}
func (s *Service) SetLevelSystemEnabled(ctx context.Context, guildID int64, enabled bool) error {
return s.settings.SetLevelSystemEnabled(ctx, guildID, enabled)
}
func (s *Service) SetLevelUpChannel(ctx context.Context, guildID int64, channelID int64) error {
return s.settings.SetLevelUpChannel(ctx, guildID, channelID)
}
func (s *Service) SetLevelUpMessage(ctx context.Context, guildID int64, message string) error {
return s.settings.SetLevelUpMessage(ctx, guildID, message)
}
func (s *Service) GetLevelUpMessage(ctx context.Context, guildID int64) (string, error) {
return s.settings.GetLevelUpMessage(ctx, guildID)
}
func (s *Service) SetRoleRewardForLevel(ctx context.Context, guildID int64, level int, roleID int64) error {
return s.settings.SetRoleRewardForLevel(ctx, guildID, level, roleID)
}
+22
View File
@@ -0,0 +1,22 @@
package services
import (
"velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings"
)
type Services struct {
Level *level.Service
LevelSettings *levelsettings.Service
}
var Global *Services
func NewServices(level *level.Service, levelSettings *levelsettings.Service) *Services {
s := &Services{
Level: level,
LevelSettings: levelSettings,
}
Global = s
return s
}