refactor(channelserver): eliminate *sqlx.Rows/*sql.Rows from repository interfaces

Move scan loops from handlers into repository methods so that interfaces
return typed slices instead of leaking database cursors. This fixes
resource leaks (7 of 12 call sites never closed rows) and makes all
12 methods mockable for unit tests.

Affected repos: CafeRepo, ShopRepo, EventRepo, RengokuRepo, DivaRepo,
ScenarioRepo, MiscRepo, MercenaryRepo. New structs: DivaEvent,
MercenaryLoan, GuildHuntCatUsage. EventRepo.GetEventQuests left as-is
(requires broader Server refactor).
This commit is contained in:
Houmgaor
2026-02-21 14:16:58 +01:00
parent a9cca84bc3
commit 2be589beae
17 changed files with 203 additions and 178 deletions

View File

@@ -118,59 +118,45 @@ type CafeBonus struct {
func handleMsgMhfGetCafeDurationBonusInfo(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfGetCafeDurationBonusInfo)
bf := byteframe.NewByteFrame()
var count uint32
rows, err := s.server.cafeRepo.GetBonuses(s.charID)
bonuses, err := s.server.cafeRepo.GetBonuses(s.charID)
if err != nil {
s.logger.Error("Error getting cafebonus", zap.Error(err))
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 4))
} else {
for rows.Next() {
count++
cafeBonus := &CafeBonus{}
err = rows.StructScan(&cafeBonus)
if err != nil {
s.logger.Error("Error scanning cafebonus", zap.Error(err))
}
bf.WriteUint32(cafeBonus.TimeReq)
bf.WriteUint32(cafeBonus.ItemType)
bf.WriteUint32(cafeBonus.ItemID)
bf.WriteUint32(cafeBonus.Quantity)
bf.WriteBool(cafeBonus.Claimed)
}
resp := byteframe.NewByteFrame()
resp.WriteUint32(0)
resp.WriteUint32(uint32(TimeAdjusted().Unix()))
resp.WriteUint32(count)
resp.WriteBytes(bf.Data())
doAckBufSucceed(s, pkt.AckHandle, resp.Data())
return
}
bf := byteframe.NewByteFrame()
for _, cb := range bonuses {
bf.WriteUint32(cb.TimeReq)
bf.WriteUint32(cb.ItemType)
bf.WriteUint32(cb.ItemID)
bf.WriteUint32(cb.Quantity)
bf.WriteBool(cb.Claimed)
}
resp := byteframe.NewByteFrame()
resp.WriteUint32(0)
resp.WriteUint32(uint32(TimeAdjusted().Unix()))
resp.WriteUint32(uint32(len(bonuses)))
resp.WriteBytes(bf.Data())
doAckBufSucceed(s, pkt.AckHandle, resp.Data())
}
func handleMsgMhfReceiveCafeDurationBonus(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfReceiveCafeDurationBonus)
bf := byteframe.NewByteFrame()
var count uint32
bf.WriteUint32(0)
rows, err := s.server.cafeRepo.GetClaimable(s.charID, TimeAdjusted().Unix()-s.sessionStart)
claimable, err := s.server.cafeRepo.GetClaimable(s.charID, TimeAdjusted().Unix()-s.sessionStart)
if err != nil || !mhfcourse.CourseExists(30, s.courses) {
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
} else {
for rows.Next() {
cafeBonus := &CafeBonus{}
err = rows.StructScan(cafeBonus)
if err != nil {
continue
}
count++
bf.WriteUint32(cafeBonus.ID)
bf.WriteUint32(cafeBonus.ItemType)
bf.WriteUint32(cafeBonus.ItemID)
bf.WriteUint32(cafeBonus.Quantity)
for _, cb := range claimable {
bf.WriteUint32(cb.ID)
bf.WriteUint32(cb.ItemType)
bf.WriteUint32(cb.ItemID)
bf.WriteUint32(cb.Quantity)
}
_, _ = bf.Seek(0, io.SeekStart)
bf.WriteUint32(count)
bf.WriteUint32(uint32(len(claimable)))
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
}
}

View File

@@ -78,16 +78,13 @@ func handleMsgMhfGetUdSchedule(s *Session, p mhfpacket.MHFPacket) {
const divaIDSentinel = uint32(0xCAFEBEEF)
id, start := divaIDSentinel, uint32(0)
rows, err := s.server.divaRepo.GetEvents()
events, err := s.server.divaRepo.GetEvents()
if err != nil {
s.logger.Error("Failed to query diva schedule", zap.Error(err))
} else {
defer func() { _ = rows.Close() }()
for rows.Next() {
if err := rows.Scan(&id, &start); err != nil {
s.logger.Error("Failed to scan diva schedule row", zap.Error(err))
}
}
} else if len(events) > 0 {
last := events[len(events)-1]
id = last.ID
start = last.StartTime
}
var timestamps []uint32

View File

@@ -136,18 +136,11 @@ func handleMsgMhfGetKeepLoginBoostStatus(s *Session, p mhfpacket.MHFPacket) {
bf := byteframe.NewByteFrame()
var loginBoosts []loginBoost
rows, err := s.server.eventRepo.GetLoginBoosts(s.charID)
loginBoosts, err := s.server.eventRepo.GetLoginBoosts(s.charID)
if err != nil || s.server.erupeConfig.GameplayOptions.DisableLoginBoost {
_ = rows.Close()
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 35))
return
}
for rows.Next() {
var temp loginBoost
_ = rows.StructScan(&temp)
loginBoosts = append(loginBoosts, temp)
}
if len(loginBoosts) == 0 {
temp := TimeWeekStart()
loginBoosts = []loginBoost{

View File

@@ -225,27 +225,18 @@ func handleMsgMhfReadMercenaryW(s *Session, p mhfpacket.MHFPacket) {
}
if pkt.Op != 2 && pkt.Op != 5 {
var loans uint8
temp := byteframe.NewByteFrame()
rows, err := s.server.mercenaryRepo.GetMercenaryLoans(s.charID)
loans, err := s.server.mercenaryRepo.GetMercenaryLoans(s.charID)
if err != nil {
s.logger.Error("Failed to query mercenary loans", zap.Error(err))
} else {
defer func() { _ = rows.Close() }()
for rows.Next() {
if err := rows.Scan(&name, &cid, &pactID); err != nil {
continue
}
loans++
temp.WriteUint32(uint32(pactID))
temp.WriteUint32(cid)
temp.WriteUint32(uint32(TimeAdjusted().Unix()))
temp.WriteUint32(uint32(TimeAdjusted().Add(time.Hour * 24 * 7).Unix()))
temp.WriteBytes(stringsupport.PaddedString(name, 18, true))
}
}
bf.WriteUint8(loans)
bf.WriteBytes(temp.Data())
bf.WriteUint8(uint8(len(loans)))
for _, loan := range loans {
bf.WriteUint32(uint32(loan.PactID))
bf.WriteUint32(loan.CharID)
bf.WriteUint32(uint32(TimeAdjusted().Unix()))
bf.WriteUint32(uint32(TimeAdjusted().Add(time.Hour * 24 * 7).Unix()))
bf.WriteBytes(stringsupport.PaddedString(loan.Name, 18, true))
}
if pkt.Op != 1 && pkt.Op != 4 {
data, _ := s.server.charRepo.LoadColumn(s.charID, "savemercenary")
@@ -393,36 +384,28 @@ func getGuildAirouList(s *Session) []Airou {
if err != nil {
return guildCats
}
rows, err := s.server.mercenaryRepo.GetGuildHuntCatsUsed(s.charID)
usages, err := s.server.mercenaryRepo.GetGuildHuntCatsUsed(s.charID)
if err != nil {
s.logger.Warn("Failed to get recently used airous", zap.Error(err))
return guildCats
}
var csvTemp string
var startTemp time.Time
for rows.Next() {
err = rows.Scan(&csvTemp, &startTemp)
if err != nil {
continue
}
if startTemp.Add(time.Second * time.Duration(s.server.erupeConfig.GameplayOptions.TreasureHuntPartnyaCooldown)).Before(TimeAdjusted()) {
for i, j := range stringsupport.CSVElems(csvTemp) {
for _, usage := range usages {
if usage.Start.Add(time.Second * time.Duration(s.server.erupeConfig.GameplayOptions.TreasureHuntPartnyaCooldown)).Before(TimeAdjusted()) {
for i, j := range stringsupport.CSVElems(usage.CatsUsed) {
bannedCats[uint32(j)] = i
}
}
}
rows, err = s.server.mercenaryRepo.GetGuildAirou(guild.ID)
airouData, err := s.server.mercenaryRepo.GetGuildAirou(guild.ID)
if err != nil {
s.logger.Warn("Selecting otomoairou based on guild failed", zap.Error(err))
return guildCats
}
for rows.Next() {
var data []byte
err = rows.Scan(&data)
if err != nil || len(data) == 0 {
for _, data := range airouData {
if len(data) == 0 {
continue
}
// first byte has cat existence in general, can skip if 0

View File

@@ -254,18 +254,13 @@ func handleMsgMhfGetTrendWeapon(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfGetTrendWeapon)
trendWeapons := [14][3]TrendWeapon{}
for i := uint8(0); i < 14; i++ {
rows, err := s.server.miscRepo.GetTrendWeapons(i)
ids, err := s.server.miscRepo.GetTrendWeapons(i)
if err != nil {
continue
}
j := 0
for rows.Next() {
for j, id := range ids {
trendWeapons[i][j].WeaponType = i
if err := rows.Scan(&trendWeapons[i][j].WeaponID); err != nil {
s.logger.Error("Failed to scan trend weapon", zap.Error(err))
break
}
j++
trendWeapons[i][j].WeaponID = id
}
}

View File

@@ -215,7 +215,6 @@ func handleMsgMhfEnumerateRengokuRanking(s *Session, p mhfpacket.MHFPacket) {
}
}
var score RengokuScore
var selfExist bool
i := uint32(1)
bf := byteframe.NewByteFrame()
@@ -225,16 +224,14 @@ func handleMsgMhfEnumerateRengokuRanking(s *Session, p mhfpacket.MHFPacket) {
if guild != nil {
guildID = guild.ID
}
rows, err := s.server.rengokuRepo.GetRanking(pkt.Leaderboard, guildID)
scores, err := s.server.rengokuRepo.GetRanking(pkt.Leaderboard, guildID)
if err != nil {
s.logger.Error("Failed to query rengoku ranking", zap.Error(err))
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 11))
return
}
defer func() { _ = rows.Close() }()
for rows.Next() {
_ = rows.StructScan(&score)
for _, score := range scores {
if score.Name == s.Name {
bf.WriteUint32(i)
bf.WriteUint32(score.Score)

View File

@@ -20,22 +20,12 @@ type Scenario struct {
func handleMsgMhfInfoScenarioCounter(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfInfoScenarioCounter)
var scenarios []Scenario
var scenario Scenario
scenarioData, err := s.server.scenarioRepo.GetCounters()
scenarios, err := s.server.scenarioRepo.GetCounters()
if err != nil {
_ = scenarioData.Close()
s.logger.Error("Failed to get scenario counter info from db", zap.Error(err))
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1))
return
}
for scenarioData.Next() {
err = scenarioData.Scan(&scenario.MainID, &scenario.CategoryID)
if err != nil {
continue
}
scenarios = append(scenarios, scenario)
}
// Trim excess scenarios
if len(scenarios) > 128 {

View File

@@ -57,17 +57,9 @@ func writeShopItems(bf *byteframe.ByteFrame, items []ShopItem, mode cfg.Mode) {
}
func getShopItems(s *Session, shopType uint8, shopID uint32) []ShopItem {
var items []ShopItem
var temp ShopItem
rows, err := s.server.shopRepo.GetShopItems(shopType, shopID, s.charID)
if err == nil {
for rows.Next() {
err = rows.StructScan(&temp)
if err != nil {
continue
}
items = append(items, temp)
}
items, err := s.server.shopRepo.GetShopItems(shopType, shopID, s.charID)
if err != nil {
return nil
}
return items
}
@@ -270,20 +262,11 @@ func handleMsgMhfGetFpointExchangeList(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfGetFpointExchangeList)
bf := byteframe.NewByteFrame()
var exchange FPointExchange
var exchanges []FPointExchange
exchanges, _ := s.server.shopRepo.GetFpointExchangeList()
var buyables uint16
rows, err := s.server.shopRepo.GetFpointExchangeList()
if err == nil {
for rows.Next() {
err = rows.StructScan(&exchange)
if err != nil {
continue
}
if exchange.Buyable {
buyables++
}
exchanges = append(exchanges, exchange)
for _, e := range exchanges {
if e.Buyable {
buyables++
}
}
if s.server.erupeConfig.RealClientMode <= cfg.Z2 {

View File

@@ -21,8 +21,9 @@ func (r *CafeRepository) ResetAccepted(charID uint32) error {
}
// GetBonuses returns all cafe bonuses with their claimed status for a character.
func (r *CafeRepository) GetBonuses(charID uint32) (*sqlx.Rows, error) {
return r.db.Queryx(`
func (r *CafeRepository) GetBonuses(charID uint32) ([]CafeBonus, error) {
var result []CafeBonus
err := r.db.Select(&result, `
SELECT cb.id, time_req, item_type, item_id, quantity,
(
SELECT count(*)
@@ -30,11 +31,13 @@ func (r *CafeRepository) GetBonuses(charID uint32) (*sqlx.Rows, error) {
WHERE cb.id = ca.cafe_id AND ca.character_id = $1
)::int::bool AS claimed
FROM cafebonus cb ORDER BY id ASC;`, charID)
return result, err
}
// GetClaimable returns unclaimed cafe bonuses where the character has enough accumulated time.
func (r *CafeRepository) GetClaimable(charID uint32, elapsedSec int64) (*sqlx.Rows, error) {
return r.db.Queryx(`
func (r *CafeRepository) GetClaimable(charID uint32, elapsedSec int64) ([]CafeBonus, error) {
var result []CafeBonus
err := r.db.Select(&result, `
SELECT c.id, time_req, item_type, item_id, quantity
FROM cafebonus c
WHERE (
@@ -46,6 +49,7 @@ func (r *CafeRepository) GetClaimable(charID uint32, elapsedSec int64) (*sqlx.Ro
FROM characters ch
WHERE ch.id = $1
) >= time_req`, charID, elapsedSec)
return result, err
}
// GetBonusItem returns the item type and quantity for a specific cafe bonus.

View File

@@ -26,7 +26,15 @@ func (r *DivaRepository) InsertEvent(startEpoch uint32) error {
return err
}
// GetEvents returns all diva events with their ID and start_time epoch.
func (r *DivaRepository) GetEvents() (*sqlx.Rows, error) {
return r.db.Queryx("SELECT id, (EXTRACT(epoch FROM start_time)::int) as start_time FROM events WHERE event_type='diva'")
// DivaEvent represents a diva event row with ID and start_time epoch.
type DivaEvent struct {
ID uint32 `db:"id"`
StartTime uint32 `db:"start_time"`
}
// GetEvents returns all diva events with their ID and start_time epoch.
func (r *DivaRepository) GetEvents() ([]DivaEvent, error) {
var result []DivaEvent
err := r.db.Select(&result, "SELECT id, (EXTRACT(epoch FROM start_time)::int) as start_time FROM events WHERE event_type='diva'")
return result, err
}

View File

@@ -31,8 +31,10 @@ func (r *EventRepository) InsertFeatureWeapon(startTime time.Time, features uint
}
// GetLoginBoosts returns all login boost rows for a character, ordered by week_req.
func (r *EventRepository) GetLoginBoosts(charID uint32) (*sqlx.Rows, error) {
return r.db.Queryx("SELECT week_req, expiration, reset FROM login_boost WHERE char_id=$1 ORDER BY week_req", charID)
func (r *EventRepository) GetLoginBoosts(charID uint32) ([]loginBoost, error) {
var result []loginBoost
err := r.db.Select(&result, "SELECT week_req, expiration, reset FROM login_boost WHERE char_id=$1 ORDER BY week_req", charID)
return result, err
}
// InsertLoginBoost creates a new login boost entry.

View File

@@ -3,8 +3,6 @@ package channelserver
import (
"database/sql"
"time"
"github.com/jmoiron/sqlx"
)
// Repository interfaces decouple handlers from concrete PostgreSQL implementations,
@@ -224,7 +222,7 @@ type TowerRepo interface {
// RengokuRepo defines the contract for rengoku score/ranking data access.
type RengokuRepo interface {
UpsertScore(charID uint32, maxStagesMp, maxPointsMp, maxStagesSp, maxPointsSp uint32) error
GetRanking(leaderboard uint32, guildID uint32) (*sqlx.Rows, error)
GetRanking(leaderboard uint32, guildID uint32) ([]RengokuScore, error)
}
// MailRepo defines the contract for in-game mail data access.
@@ -270,7 +268,7 @@ type SessionRepo interface {
type EventRepo interface {
GetFeatureWeapon(startTime time.Time) (activeFeature, error)
InsertFeatureWeapon(startTime time.Time, features uint32) error
GetLoginBoosts(charID uint32) (*sqlx.Rows, error)
GetLoginBoosts(charID uint32) ([]loginBoost, error)
InsertLoginBoost(charID uint32, weekReq uint8, expiration, reset time.Time) error
UpdateLoginBoost(charID uint32, weekReq uint8, expiration, reset time.Time) error
GetEventQuests() (*sql.Rows, error)
@@ -287,17 +285,17 @@ type AchievementRepo interface {
// ShopRepo defines the contract for shop data access.
type ShopRepo interface {
GetShopItems(shopType uint8, shopID uint32, charID uint32) (*sqlx.Rows, error)
GetShopItems(shopType uint8, shopID uint32, charID uint32) ([]ShopItem, error)
RecordPurchase(charID, shopItemID, quantity uint32) error
GetFpointItem(tradeID uint32) (quantity, fpoints int, err error)
GetFpointExchangeList() (*sqlx.Rows, error)
GetFpointExchangeList() ([]FPointExchange, error)
}
// CafeRepo defines the contract for cafe bonus data access.
type CafeRepo interface {
ResetAccepted(charID uint32) error
GetBonuses(charID uint32) (*sqlx.Rows, error)
GetClaimable(charID uint32, elapsedSec int64) (*sqlx.Rows, error)
GetBonuses(charID uint32) ([]CafeBonus, error)
GetClaimable(charID uint32, elapsedSec int64) ([]CafeBonus, error)
GetBonusItem(bonusID uint32) (itemType, quantity uint32, err error)
AcceptBonus(bonusID, charID uint32) error
}
@@ -314,25 +312,25 @@ type GoocooRepo interface {
type DivaRepo interface {
DeleteEvents() error
InsertEvent(startEpoch uint32) error
GetEvents() (*sqlx.Rows, error)
GetEvents() ([]DivaEvent, error)
}
// MiscRepo defines the contract for miscellaneous data access.
type MiscRepo interface {
GetTrendWeapons(weaponType uint8) (*sql.Rows, error)
GetTrendWeapons(weaponType uint8) ([]uint16, error)
UpsertTrendWeapon(weaponID uint16, weaponType uint8) error
}
// ScenarioRepo defines the contract for scenario counter data access.
type ScenarioRepo interface {
GetCounters() (*sqlx.Rows, error)
GetCounters() ([]Scenario, error)
}
// MercenaryRepo defines the contract for mercenary/rasta data access.
type MercenaryRepo interface {
NextRastaID() (uint32, error)
NextAirouID() (uint32, error)
GetMercenaryLoans(charID uint32) (*sql.Rows, error)
GetGuildHuntCatsUsed(charID uint32) (*sql.Rows, error)
GetGuildAirou(guildID uint32) (*sql.Rows, error)
GetMercenaryLoans(charID uint32) ([]MercenaryLoan, error)
GetGuildHuntCatsUsed(charID uint32) ([]GuildHuntCatUsage, error)
GetGuildAirou(guildID uint32) ([][]byte, error)
}

View File

@@ -1,7 +1,8 @@
package channelserver
import (
"database/sql"
"fmt"
"time"
"github.com/jmoiron/sqlx"
)
@@ -30,21 +31,73 @@ func (r *MercenaryRepository) NextAirouID() (uint32, error) {
return id, err
}
// MercenaryLoan represents a character that has a pact with a rasta.
type MercenaryLoan struct {
Name string
CharID uint32
PactID int
}
// GetMercenaryLoans returns characters that have a pact with the given character's rasta_id.
func (r *MercenaryRepository) GetMercenaryLoans(charID uint32) (*sql.Rows, error) {
return r.db.Query("SELECT name, id, pact_id FROM characters WHERE pact_id=(SELECT rasta_id FROM characters WHERE id=$1)", charID)
func (r *MercenaryRepository) GetMercenaryLoans(charID uint32) ([]MercenaryLoan, error) {
rows, err := r.db.Query("SELECT name, id, pact_id FROM characters WHERE pact_id=(SELECT rasta_id FROM characters WHERE id=$1)", charID)
if err != nil {
return nil, fmt.Errorf("query mercenary loans: %w", err)
}
defer rows.Close()
var result []MercenaryLoan
for rows.Next() {
var l MercenaryLoan
if err := rows.Scan(&l.Name, &l.CharID, &l.PactID); err != nil {
return nil, fmt.Errorf("scan mercenary loan: %w", err)
}
result = append(result, l)
}
return result, rows.Err()
}
// GuildHuntCatUsage represents cats_used and start time from a guild hunt.
type GuildHuntCatUsage struct {
CatsUsed string
Start time.Time
}
// GetGuildHuntCatsUsed returns cats_used and start from guild_hunts for a given character.
func (r *MercenaryRepository) GetGuildHuntCatsUsed(charID uint32) (*sql.Rows, error) {
return r.db.Query(`SELECT cats_used, start FROM guild_hunts gh
func (r *MercenaryRepository) GetGuildHuntCatsUsed(charID uint32) ([]GuildHuntCatUsage, error) {
rows, err := r.db.Query(`SELECT cats_used, start FROM guild_hunts gh
INNER JOIN characters c ON gh.host_id = c.id WHERE c.id=$1`, charID)
if err != nil {
return nil, fmt.Errorf("query guild hunt cats: %w", err)
}
defer rows.Close()
var result []GuildHuntCatUsage
for rows.Next() {
var u GuildHuntCatUsage
if err := rows.Scan(&u.CatsUsed, &u.Start); err != nil {
return nil, fmt.Errorf("scan guild hunt cat: %w", err)
}
result = append(result, u)
}
return result, rows.Err()
}
// GetGuildAirou returns otomoairou data for all characters in a guild.
func (r *MercenaryRepository) GetGuildAirou(guildID uint32) (*sql.Rows, error) {
return r.db.Query(`SELECT c.otomoairou FROM characters c
func (r *MercenaryRepository) GetGuildAirou(guildID uint32) ([][]byte, error) {
rows, err := r.db.Query(`SELECT c.otomoairou FROM characters c
INNER JOIN guild_characters gc ON gc.character_id = c.id
WHERE gc.guild_id = $1 AND c.otomoairou IS NOT NULL
ORDER BY c.id LIMIT 60`, guildID)
if err != nil {
return nil, fmt.Errorf("query guild airou: %w", err)
}
defer rows.Close()
var result [][]byte
for rows.Next() {
var data []byte
if err := rows.Scan(&data); err != nil {
return nil, fmt.Errorf("scan guild airou: %w", err)
}
result = append(result, data)
}
return result, rows.Err()
}

View File

@@ -1,7 +1,7 @@
package channelserver
import (
"database/sql"
"fmt"
"github.com/jmoiron/sqlx"
)
@@ -17,8 +17,21 @@ func NewMiscRepository(db *sqlx.DB) *MiscRepository {
}
// GetTrendWeapons returns the top 3 weapon IDs for a given weapon type, ordered by count descending.
func (r *MiscRepository) GetTrendWeapons(weaponType uint8) (*sql.Rows, error) {
return r.db.Query("SELECT weapon_id FROM trend_weapons WHERE weapon_type=$1 ORDER BY count DESC LIMIT 3", weaponType)
func (r *MiscRepository) GetTrendWeapons(weaponType uint8) ([]uint16, error) {
rows, err := r.db.Query("SELECT weapon_id FROM trend_weapons WHERE weapon_type=$1 ORDER BY count DESC LIMIT 3", weaponType)
if err != nil {
return nil, fmt.Errorf("query trend_weapons: %w", err)
}
defer rows.Close()
var result []uint16
for rows.Next() {
var id uint16
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan trend_weapons: %w", err)
}
result = append(result, id)
}
return result, rows.Err()
}
// UpsertTrendWeapon increments the count for a weapon, inserting it if it doesn't exist.

View File

@@ -62,15 +62,19 @@ func rengokuIsGuildFiltered(leaderboard uint32) bool {
// GetRanking returns rengoku scores for the given leaderboard.
// For guild-scoped leaderboards (2,3,6,7), guildID filters the results.
func (r *RengokuRepository) GetRanking(leaderboard uint32, guildID uint32) (*sqlx.Rows, error) {
func (r *RengokuRepository) GetRanking(leaderboard uint32, guildID uint32) ([]RengokuScore, error) {
col := rengokuColumnForLeaderboard(leaderboard)
var result []RengokuScore
var err error
if rengokuIsGuildFiltered(leaderboard) {
return r.db.Queryx(
err = r.db.Select(&result,
fmt.Sprintf("SELECT %s AS score %s WHERE guild_id=$1 ORDER BY %s DESC", col, rengokuScoreQueryRepo, col),
guildID,
)
} else {
err = r.db.Select(&result,
fmt.Sprintf("SELECT %s AS score %s ORDER BY %s DESC", col, rengokuScoreQueryRepo, col),
)
}
return r.db.Queryx(
fmt.Sprintf("SELECT %s AS score %s ORDER BY %s DESC", col, rengokuScoreQueryRepo, col),
)
return result, err
}

View File

@@ -1,6 +1,8 @@
package channelserver
import (
"fmt"
"github.com/jmoiron/sqlx"
)
@@ -15,6 +17,19 @@ func NewScenarioRepository(db *sqlx.DB) *ScenarioRepository {
}
// GetCounters returns all scenario counters.
func (r *ScenarioRepository) GetCounters() (*sqlx.Rows, error) {
return r.db.Queryx("SELECT scenario_id, category_id FROM scenario_counter")
func (r *ScenarioRepository) GetCounters() ([]Scenario, error) {
rows, err := r.db.Query("SELECT scenario_id, category_id FROM scenario_counter")
if err != nil {
return nil, fmt.Errorf("query scenario_counter: %w", err)
}
defer rows.Close()
var result []Scenario
for rows.Next() {
var s Scenario
if err := rows.Scan(&s.MainID, &s.CategoryID); err != nil {
return nil, fmt.Errorf("scan scenario_counter: %w", err)
}
result = append(result, s)
}
return result, rows.Err()
}

View File

@@ -15,11 +15,13 @@ func NewShopRepository(db *sqlx.DB) *ShopRepository {
}
// GetShopItems returns shop items with per-character purchase counts.
func (r *ShopRepository) GetShopItems(shopType uint8, shopID uint32, charID uint32) (*sqlx.Rows, error) {
return r.db.Queryx(`SELECT id, item_id, cost, quantity, min_hr, min_sr, min_gr, store_level, max_quantity,
func (r *ShopRepository) GetShopItems(shopType uint8, shopID uint32, charID uint32) ([]ShopItem, error) {
var result []ShopItem
err := r.db.Select(&result, `SELECT id, item_id, cost, quantity, min_hr, min_sr, min_gr, store_level, max_quantity,
COALESCE((SELECT bought FROM shop_items_bought WHERE shop_item_id=si.id AND character_id=$3), 0) as used_quantity,
road_floors, road_fatalis FROM shop_items si WHERE shop_type=$1 AND shop_id=$2
`, shopType, shopID, charID)
return result, err
}
// RecordPurchase upserts a purchase record, adding to the bought count.
@@ -39,6 +41,8 @@ func (r *ShopRepository) GetFpointItem(tradeID uint32) (quantity, fpoints int, e
}
// GetFpointExchangeList returns all frontier point exchange items ordered by buyable status.
func (r *ShopRepository) GetFpointExchangeList() (*sqlx.Rows, error) {
return r.db.Queryx(`SELECT id, item_type, item_id, quantity, fpoints, buyable FROM fpoint_items ORDER BY buyable DESC`)
func (r *ShopRepository) GetFpointExchangeList() ([]FPointExchange, error) {
var result []FPointExchange
err := r.db.Select(&result, `SELECT id, item_type, item_id, quantity, fpoints, buyable FROM fpoint_items ORDER BY buyable DESC`)
return result, err
}