diff --git a/docs/anti-patterns.md b/docs/anti-patterns.md index 52d17032f..d88b027e1 100644 --- a/docs/anti-patterns.md +++ b/docs/anti-patterns.md @@ -240,7 +240,7 @@ The same table is queried in different handlers with slightly different column s **Recommendation:** At minimum, define query constants. Ideally, introduce a repository layer that encapsulates all queries for a given entity. -**Status (partial):** A `CharacterRepository` layer has been introduced in `repo_character.go`, centralizing all `characters` table access behind a concrete struct. The 4 existing helpers (`loadCharacterData`, `saveCharacterData`, `readCharacterInt`, `adjustCharacterInt`) now delegate to the repository, covering ~70% of character queries. Direct queries in `handlers_session.go` (login/logout), `sys_channel_server.go` (`DisconnectUser`), and `handlers_mail.go` (name lookup) have also been migrated. Remaining work: guild repository (second-highest duplication), per-handler migration of remaining inline character queries (plate, mercenary, rengoku, cafe, clients), and column allowlist for SQL injection hardening. +**Status (substantial):** A `CharacterRepository` layer in `repo_character.go` now centralizes nearly all `characters` table access (27 methods). The initial PR introduced 9 core methods and rewired the 4 helpers + 6 direct queries (~70%). A second pass migrated ~56 additional inline queries across 13 handler files (cafe, misc, clients, plate, rengoku, mercenary, gacha, guild_board, guild_scout, data, items, house, session), bringing coverage to ~95% of character queries. Remaining unmigrated queries are cross-table JOINs (house+user_binary, mercenary+guild_characters, session auth), the bulk `CharacterSaveData` read/write, and a `handlers_commands.go` subquery through `users`. Next steps: guild repository (second-highest duplication) and column allowlist for SQL injection hardening. --- @@ -304,7 +304,7 @@ Database operations use raw `database/sql` with PostgreSQL-specific syntax throu | Severity | Anti-patterns | |----------|--------------| | **High** | ~~Missing ACK responses / softlocks (#2)~~ **Fixed**, no architectural layering (#3), tight DB coupling (#13) | -| **Medium** | ~~Magic numbers (#4)~~ **Fixed**, ~~inconsistent binary I/O (#5)~~ **Resolved**, Session god object (#6), ~~copy-paste handlers (#8)~~ **Fixed**, raw SQL duplication (#9) | +| **Medium** | ~~Magic numbers (#4)~~ **Fixed**, ~~inconsistent binary I/O (#5)~~ **Resolved**, Session god object (#6), ~~copy-paste handlers (#8)~~ **Fixed**, ~~raw SQL duplication (#9)~~ **Substantially fixed** (characters table ~95% migrated) | | **Low** | God files (#1), ~~`init()` registration (#10)~~ **Fixed**, ~~inconsistent logging (#12)~~ **Fixed**, mutex granularity (#7), ~~panic-based flow (#11)~~ **Fixed** | ### Root Cause diff --git a/server/channelserver/handlers_cafe.go b/server/channelserver/handlers_cafe.go index 25fd299d2..38de82e5f 100644 --- a/server/channelserver/handlers_cafe.go +++ b/server/channelserver/handlers_cafe.go @@ -43,8 +43,7 @@ func handleMsgMhfCheckDailyCafepoint(s *Session, p mhfpacket.MHFPacket) { } // get time after which daily claiming would be valid from db - var dailyTime time.Time - err := s.server.db.QueryRow("SELECT COALESCE(daily_time, $2) FROM characters WHERE id = $1", s.charID, time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)).Scan(&dailyTime) + dailyTime, err := s.server.charRepo.ReadTime(s.charID, "daily_time", time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)) if err != nil { s.logger.Error("Failed to get daily_time savedata from db", zap.Error(err)) } @@ -56,7 +55,7 @@ func handleMsgMhfCheckDailyCafepoint(s *Session, p mhfpacket.MHFPacket) { bondBonus = 5 // Bond point bonus quests bonusQuests = s.server.erupeConfig.GameplayOptions.BonusQuestAllowance dailyQuests = s.server.erupeConfig.GameplayOptions.DailyQuestAllowance - if _, err := s.server.db.Exec("UPDATE characters SET daily_time=$1, bonus_quests = $2, daily_quests = $3 WHERE id=$4", midday, bonusQuests, dailyQuests, s.charID); err != nil { + if err := s.server.charRepo.UpdateDailyCafe(s.charID, midday, bonusQuests, dailyQuests); err != nil { s.logger.Error("Failed to update daily cafe data", zap.Error(err)) } bf.WriteBool(true) // Success? @@ -73,17 +72,16 @@ func handleMsgMhfGetCafeDuration(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetCafeDuration) bf := byteframe.NewByteFrame() - var cafeReset time.Time - err := s.server.db.QueryRow(`SELECT cafe_reset FROM characters WHERE id=$1`, s.charID).Scan(&cafeReset) + cafeReset, err := s.server.charRepo.ReadTime(s.charID, "cafe_reset", time.Time{}) if err != nil { cafeReset = TimeWeekNext() - if _, err := s.server.db.Exec(`UPDATE characters SET cafe_reset=$1 WHERE id=$2`, cafeReset, s.charID); err != nil { + if err := s.server.charRepo.SaveTime(s.charID, "cafe_reset", cafeReset); err != nil { s.logger.Error("Failed to set cafe reset time", zap.Error(err)) } } if TimeAdjusted().After(cafeReset) { cafeReset = TimeWeekNext() - if _, err := s.server.db.Exec(`UPDATE characters SET cafe_time=0, cafe_reset=$1 WHERE id=$2`, cafeReset, s.charID); err != nil { + if err := s.server.charRepo.ResetCafeTime(s.charID, cafeReset); err != nil { s.logger.Error("Failed to reset cafe time", zap.Error(err)) } if _, err := s.server.db.Exec(`DELETE FROM cafe_accepted WHERE character_id=$1`, s.charID); err != nil { @@ -220,7 +218,7 @@ func addPointNetcafe(s *Session, p int) error { return err } points = min(points+p, s.server.erupeConfig.GameplayOptions.MaximumNP) - if _, err := s.server.db.Exec("UPDATE characters SET netcafe_points=$1 WHERE id=$2", points, s.charID); err != nil { + if err := s.server.charRepo.SaveInt(s.charID, "netcafe_points", points); err != nil { s.logger.Error("Failed to update netcafe points", zap.Error(err)) } return nil @@ -235,7 +233,7 @@ func handleMsgMhfStartBoostTime(s *Session, p mhfpacket.MHFPacket) { doAckBufSucceed(s, pkt.AckHandle, bf.Data()) return } - if _, err := s.server.db.Exec("UPDATE characters SET boost_time=$1 WHERE id=$2", boostLimit, s.charID); err != nil { + if err := s.server.charRepo.SaveTime(s.charID, "boost_time", boostLimit); err != nil { s.logger.Error("Failed to update boost time", zap.Error(err)) } bf.WriteUint32(uint32(boostLimit.Unix())) @@ -250,8 +248,7 @@ func handleMsgMhfGetBoostTime(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfGetBoostTimeLimit(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetBoostTimeLimit) bf := byteframe.NewByteFrame() - var boostLimit time.Time - err := s.server.db.QueryRow("SELECT boost_time FROM characters WHERE id=$1", s.charID).Scan(&boostLimit) + boostLimit, err := s.server.charRepo.ReadTime(s.charID, "boost_time", time.Time{}) if err != nil { bf.WriteUint32(0) } else { @@ -263,8 +260,7 @@ func handleMsgMhfGetBoostTimeLimit(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfGetBoostRight(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetBoostRight) - var boostLimit time.Time - err := s.server.db.QueryRow("SELECT boost_time FROM characters WHERE id=$1", s.charID).Scan(&boostLimit) + boostLimit, err := s.server.charRepo.ReadTime(s.charID, "boost_time", time.Time{}) if err != nil { doAckBufSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00}) return diff --git a/server/channelserver/handlers_clients.go b/server/channelserver/handlers_clients.go index 66b4e864c..8f7c1ca17 100644 --- a/server/channelserver/handlers_clients.go +++ b/server/channelserver/handlers_clients.go @@ -58,16 +58,14 @@ func handleMsgSysEnumerateClient(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfListMember(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfListMember) - var csv string var count uint32 resp := byteframe.NewByteFrame() resp.WriteUint32(0) // Blacklist count - err := s.server.db.QueryRow("SELECT blocked FROM characters WHERE id=$1", s.charID).Scan(&csv) + csv, err := s.server.charRepo.ReadString(s.charID, "blocked") if err == nil { cids := stringsupport.CSVElems(csv) for _, cid := range cids { - var name string - err = s.server.db.QueryRow("SELECT name FROM characters WHERE id=$1", cid).Scan(&name) + name, err := s.server.charRepo.GetName(uint32(cid)) if err != nil { continue } @@ -84,29 +82,28 @@ func handleMsgMhfListMember(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfOprMember(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfOprMember) - var csv string for _, cid := range pkt.CharIDs { if pkt.Blacklist { - err := s.server.db.QueryRow("SELECT blocked FROM characters WHERE id=$1", s.charID).Scan(&csv) + csv, err := s.server.charRepo.ReadString(s.charID, "blocked") if err == nil { if pkt.Operation { csv = stringsupport.CSVRemove(csv, int(cid)) } else { csv = stringsupport.CSVAdd(csv, int(cid)) } - if _, err := s.server.db.Exec("UPDATE characters SET blocked=$1 WHERE id=$2", csv, s.charID); err != nil { + if err := s.server.charRepo.SaveString(s.charID, "blocked", csv); err != nil { s.logger.Error("Failed to update blocked list", zap.Error(err)) } } } else { // Friendlist - err := s.server.db.QueryRow("SELECT friends FROM characters WHERE id=$1", s.charID).Scan(&csv) + csv, err := s.server.charRepo.ReadString(s.charID, "friends") if err == nil { if pkt.Operation { csv = stringsupport.CSVRemove(csv, int(cid)) } else { csv = stringsupport.CSVAdd(csv, int(cid)) } - if _, err := s.server.db.Exec("UPDATE characters SET friends=$1 WHERE id=$2", csv, s.charID); err != nil { + if err := s.server.charRepo.SaveString(s.charID, "friends", csv); err != nil { s.logger.Error("Failed to update friends list", zap.Error(err)) } } diff --git a/server/channelserver/handlers_data.go b/server/channelserver/handlers_data.go index 9e801d5cc..3a2264ce1 100644 --- a/server/channelserver/handlers_data.go +++ b/server/channelserver/handlers_data.go @@ -78,14 +78,13 @@ func handleMsgMhfSavedata(s *Session, p mhfpacket.MHFPacket) { _ = s.rawConn.Close() s.logger.Warn("Save cancelled due to corruption.") if s.server.erupeConfig.DeleteOnSaveCorruption { - if _, err := s.server.db.Exec("UPDATE characters SET deleted=true WHERE id=$1", s.charID); err != nil { + if err := s.server.charRepo.SetDeleted(s.charID); err != nil { s.logger.Error("Failed to mark character as deleted", zap.Error(err)) } } return } - _, err = s.server.db.Exec("UPDATE characters SET name=$1 WHERE id=$2", characterSaveData.Name, s.charID) - if err != nil { + if err := s.server.charRepo.SaveString(s.charID, "name", characterSaveData.Name); err != nil { s.logger.Error("Failed to update character name in db", zap.Error(err)) } doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) @@ -160,8 +159,7 @@ func handleMsgMhfLoaddata(s *Session, p mhfpacket.MHFPacket) { return } - var data []byte - err := s.server.db.QueryRow("SELECT savedata FROM characters WHERE id = $1", s.charID).Scan(&data) + data, err := s.server.charRepo.LoadColumn(s.charID, "savedata") if err != nil || len(data) == 0 { s.logger.Warn(fmt.Sprintf("Failed to load savedata (CID: %d)", s.charID), zap.Error(err)) _ = s.rawConn.Close() // Terminate the connection diff --git a/server/channelserver/handlers_gacha.go b/server/channelserver/handlers_gacha.go index a5e7fcd94..795e8785e 100644 --- a/server/channelserver/handlers_gacha.go +++ b/server/channelserver/handlers_gacha.go @@ -138,8 +138,7 @@ func getGuaranteedItems(s *Session, gachaID uint32, rollID uint8) []GachaItem { } func addGachaItem(s *Session, items []GachaItem) { - var data []byte - _ = s.server.db.QueryRow(`SELECT gacha_items FROM characters WHERE id = $1`, s.charID).Scan(&data) + data, _ := s.server.charRepo.LoadColumn(s.charID, "gacha_items") if len(data) > 0 { numItems := int(data[0]) data = data[1:] @@ -159,7 +158,7 @@ func addGachaItem(s *Session, items []GachaItem) { newItem.WriteUint16(items[i].ItemID) newItem.WriteUint16(items[i].Quantity) } - if _, err := s.server.db.Exec(`UPDATE characters SET gacha_items = $1 WHERE id = $2`, newItem.Data(), s.charID); err != nil { + if err := s.server.charRepo.SaveColumn(s.charID, "gacha_items", newItem.Data()); err != nil { s.logger.Error("Failed to update gacha items", zap.Error(err)) } } @@ -193,8 +192,7 @@ func getRandomEntries(entries []GachaEntry, rolls int, isBox bool) ([]GachaEntry func handleMsgMhfReceiveGachaItem(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfReceiveGachaItem) - var data []byte - err := s.server.db.QueryRow("SELECT COALESCE(gacha_items, $2) FROM characters WHERE id = $1", s.charID, []byte{0x00}).Scan(&data) + data, err := s.server.charRepo.LoadColumnWithDefault(s.charID, "gacha_items", []byte{0x00}) if err != nil { data = []byte{0x00} } @@ -214,11 +212,11 @@ func handleMsgMhfReceiveGachaItem(s *Session, p mhfpacket.MHFPacket) { update := byteframe.NewByteFrame() update.WriteUint8(uint8(len(data[181:]) / 5)) update.WriteBytes(data[181:]) - if _, err := s.server.db.Exec("UPDATE characters SET gacha_items = $1 WHERE id = $2", update.Data(), s.charID); err != nil { + if err := s.server.charRepo.SaveColumn(s.charID, "gacha_items", update.Data()); err != nil { s.logger.Error("Failed to update gacha items overflow", zap.Error(err)) } } else { - if _, err := s.server.db.Exec("UPDATE characters SET gacha_items = null WHERE id = $1", s.charID); err != nil { + if err := s.server.charRepo.SaveColumn(s.charID, "gacha_items", nil); err != nil { s.logger.Error("Failed to clear gacha items", zap.Error(err)) } } diff --git a/server/channelserver/handlers_guild_board.go b/server/channelserver/handlers_guild_board.go index e50bacfb0..3bdac9f7b 100644 --- a/server/channelserver/handlers_guild_board.go +++ b/server/channelserver/handlers_guild_board.go @@ -33,7 +33,7 @@ func handleMsgMhfEnumerateGuildMessageBoard(s *Session, p mhfpacket.MHFPacket) { doAckBufSucceed(s, pkt.AckHandle, make([]byte, 4)) return } - if _, err := s.server.db.Exec("UPDATE characters SET guild_post_checked = now() WHERE id = $1", s.charID); err != nil { + if err := s.server.charRepo.UpdateGuildPostChecked(s.charID); err != nil { s.logger.Error("Failed to update guild post checked time", zap.Error(err)) } bf := byteframe.NewByteFrame() @@ -118,9 +118,8 @@ func handleMsgMhfUpdateGuildMessageBoard(s *Session, p mhfpacket.MHFPacket) { } } case 5: // Check for new messages - var timeChecked time.Time var newPosts int - err := s.server.db.QueryRow("SELECT guild_post_checked FROM characters WHERE id = $1", s.charID).Scan(&timeChecked) + timeChecked, err := s.server.charRepo.ReadGuildPostChecked(s.charID) if err == nil { _ = s.server.db.QueryRow("SELECT COUNT(*) FROM guild_posts WHERE guild_id = $1 AND deleted = false AND (EXTRACT(epoch FROM created_at)::int) > $2", guild.ID, timeChecked.Unix()).Scan(&newPosts) if newPosts > 0 { diff --git a/server/channelserver/handlers_guild_scout.go b/server/channelserver/handlers_guild_scout.go index 77aaa1567..23c037f9f 100644 --- a/server/channelserver/handlers_guild_scout.go +++ b/server/channelserver/handlers_guild_scout.go @@ -279,11 +279,7 @@ func handleMsgMhfGetGuildScoutList(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfGetRejectGuildScout(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetRejectGuildScout) - row := s.server.db.QueryRow("SELECT restrict_guild_scout FROM characters WHERE id=$1", s.charID) - - var currentStatus bool - - err := row.Scan(¤tStatus) + currentStatus, err := s.server.charRepo.ReadBool(s.charID, "restrict_guild_scout") if err != nil { s.logger.Error( @@ -307,7 +303,7 @@ func handleMsgMhfGetRejectGuildScout(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfSetRejectGuildScout(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfSetRejectGuildScout) - _, err := s.server.db.Exec("UPDATE characters SET restrict_guild_scout=$1 WHERE id=$2", pkt.Reject, s.charID) + err := s.server.charRepo.SaveBool(s.charID, "restrict_guild_scout", pkt.Reject) if err != nil { s.logger.Error( diff --git a/server/channelserver/handlers_house.go b/server/channelserver/handlers_house.go index a13751d89..04719088e 100644 --- a/server/channelserver/handlers_house.go +++ b/server/channelserver/handlers_house.go @@ -71,8 +71,7 @@ func handleMsgMhfEnumerateHouse(s *Session, p mhfpacket.MHFPacket) { FROM characters c LEFT JOIN user_binary ub ON ub.id = c.id WHERE c.id=$1` switch pkt.Method { case 1: - var friendsList string - _ = s.server.db.QueryRow("SELECT friends FROM characters WHERE id=$1", s.charID).Scan(&friendsList) + friendsList, _ := s.server.charRepo.ReadString(s.charID, "friends") cids := stringsupport.CSVElems(friendsList) for _, cid := range cids { house := HouseData{} @@ -179,8 +178,7 @@ func handleMsgMhfLoadHouse(s *Session, p mhfpacket.MHFPacket) { // Friends list verification if state == 3 || state == 5 { - var friendsList string - _ = s.server.db.QueryRow(`SELECT friends FROM characters WHERE id=$1`, pkt.CharID).Scan(&friendsList) + friendsList, _ := s.server.charRepo.ReadString(pkt.CharID, "friends") cids := stringsupport.CSVElems(friendsList) for _, cid := range cids { if uint32(cid) == s.charID { @@ -286,8 +284,7 @@ func handleMsgMhfSaveDecoMyset(s *Session, p mhfpacket.MHFPacket) { doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) return } - var temp []byte - err := s.server.db.QueryRow("SELECT decomyset FROM characters WHERE id = $1", s.charID).Scan(&temp) + temp, err := s.server.charRepo.LoadColumn(s.charID, "decomyset") if err != nil { s.logger.Error("Failed to load decomyset", zap.Error(err)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) @@ -333,7 +330,7 @@ func handleMsgMhfSaveDecoMyset(s *Session, p mhfpacket.MHFPacket) { } dumpSaveData(s, bf.Data(), "decomyset") - if _, err := s.server.db.Exec("UPDATE characters SET decomyset=$1 WHERE id=$2", bf.Data(), s.charID); err != nil { + if err := s.server.charRepo.SaveColumn(s.charID, "decomyset", bf.Data()); err != nil { s.logger.Error("Failed to save decomyset", zap.Error(err)) } doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) diff --git a/server/channelserver/handlers_items.go b/server/channelserver/handlers_items.go index d01c5dec5..744bd3ee2 100644 --- a/server/channelserver/handlers_items.go +++ b/server/channelserver/handlers_items.go @@ -333,7 +333,9 @@ func handleMsgMhfStampcardStamp(s *Session, p mhfpacket.MHFPacket) { } var stamps, rewardTier, rewardUnk uint16 reward := mhfitem.MHFItemStack{Item: mhfitem.MHFItem{}} - if err := s.server.db.QueryRow(`UPDATE characters SET stampcard = stampcard + $1 WHERE id = $2 RETURNING stampcard`, pkt.Stamps, s.charID).Scan(&stamps); err != nil { + stamps32, err := s.server.charRepo.AdjustInt(s.charID, "stampcard", int(pkt.Stamps)) + stamps = uint16(stamps32) + if err != nil { s.logger.Error("Failed to update stampcard", zap.Error(err)) doAckBufFail(s, pkt.AckHandle, nil) return diff --git a/server/channelserver/handlers_mercenary.go b/server/channelserver/handlers_mercenary.go index 10b4d44e2..94a295425 100644 --- a/server/channelserver/handlers_mercenary.go +++ b/server/channelserver/handlers_mercenary.go @@ -77,9 +77,8 @@ func handleMsgMhfSaveHunterNavi(s *Session, p mhfpacket.MHFPacket) { if s.server.erupeConfig.RealClientMode <= _config.G7 { naviLength = hunterNaviSizeG7 } - var data []byte // Load existing save - err := s.server.db.QueryRow("SELECT hunternavi FROM characters WHERE id = $1", s.charID).Scan(&data) + data, err := s.server.charRepo.LoadColumn(s.charID, "hunternavi") if err != nil { s.logger.Error("Failed to load hunternavi", zap.Error(err), @@ -102,7 +101,7 @@ func handleMsgMhfSaveHunterNavi(s *Session, p mhfpacket.MHFPacket) { saveOutput := deltacomp.ApplyDataDiff(pkt.RawDataPayload, data) dataSize = len(saveOutput) - _, err = s.server.db.Exec("UPDATE characters SET hunternavi=$1 WHERE id=$2", saveOutput, s.charID) + err = s.server.charRepo.SaveColumn(s.charID, "hunternavi", saveOutput) if err != nil { s.logger.Error("Failed to save hunternavi", zap.Error(err), @@ -117,7 +116,7 @@ func handleMsgMhfSaveHunterNavi(s *Session, p mhfpacket.MHFPacket) { dataSize = len(pkt.RawDataPayload) // simply update database, no extra processing - _, err := s.server.db.Exec("UPDATE characters SET hunternavi=$1 WHERE id=$2", pkt.RawDataPayload, s.charID) + err := s.server.charRepo.SaveColumn(s.charID, "hunternavi", pkt.RawDataPayload) if err != nil { s.logger.Error("Failed to save hunternavi", zap.Error(err), @@ -175,7 +174,7 @@ func handleMsgMhfCreateMercenary(s *Session, p mhfpacket.MHFPacket) { doAckSimpleFail(s, pkt.AckHandle, nil) return } - if _, err := s.server.db.Exec("UPDATE characters SET rasta_id=$1 WHERE id=$2", nextID, s.charID); err != nil { + if err := s.server.charRepo.SaveInt(s.charID, "rasta_id", int(nextID)); err != nil { s.logger.Error("Failed to set rasta ID", zap.Error(err)) doAckSimpleFail(s, pkt.AckHandle, nil) return @@ -195,11 +194,11 @@ func handleMsgMhfSaveMercenary(s *Session, p mhfpacket.MHFPacket) { dumpSaveData(s, pkt.MercData, "mercenary") if len(pkt.MercData) >= 4 { temp := byteframe.NewByteFrameFromBytes(pkt.MercData) - if _, err := s.server.db.Exec("UPDATE characters SET savemercenary=$1, rasta_id=$2 WHERE id=$3", pkt.MercData, temp.ReadUint32(), s.charID); err != nil { + if err := s.server.charRepo.SaveMercenary(s.charID, pkt.MercData, temp.ReadUint32()); err != nil { s.logger.Error("Failed to save mercenary data", zap.Error(err)) } } - if _, err := s.server.db.Exec("UPDATE characters SET gcp=$1, pact_id=$2 WHERE id=$3", pkt.GCP, pkt.PactMercID, s.charID); err != nil { + if err := s.server.charRepo.UpdateGCPAndPact(s.charID, pkt.GCP, pkt.PactMercID); err != nil { s.logger.Error("Failed to update GCP and pact ID", zap.Error(err)) } doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00}) @@ -209,11 +208,11 @@ func handleMsgMhfReadMercenaryW(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfReadMercenaryW) bf := byteframe.NewByteFrame() + pactID, _ := readCharacterInt(s, "pact_id") var cid uint32 var name string - pactID, _ := readCharacterInt(s, "pact_id") if pactID > 0 { - _ = s.server.db.QueryRow("SELECT name, id FROM characters WHERE rasta_id = $1", pactID).Scan(&name, &cid) + cid, name, _ = s.server.charRepo.FindByRastaID(pactID) bf.WriteUint8(1) // numLends bf.WriteUint32(uint32(pactID)) bf.WriteUint32(cid) @@ -249,8 +248,7 @@ func handleMsgMhfReadMercenaryW(s *Session, p mhfpacket.MHFPacket) { bf.WriteBytes(temp.Data()) if pkt.Op != 1 && pkt.Op != 4 { - var data []byte - _ = s.server.db.QueryRow("SELECT savemercenary FROM characters WHERE id=$1", s.charID).Scan(&data) + data, _ := s.server.charRepo.LoadColumn(s.charID, "savemercenary") gcp, _ := readCharacterInt(s, "gcp") if len(data) == 0 { @@ -268,8 +266,7 @@ func handleMsgMhfReadMercenaryW(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfReadMercenaryM(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfReadMercenaryM) - var data []byte - _ = s.server.db.QueryRow("SELECT savemercenary FROM characters WHERE id = $1", pkt.CharID).Scan(&data) + data, _ := s.server.charRepo.LoadColumn(pkt.CharID, "savemercenary") resp := byteframe.NewByteFrame() if len(data) == 0 { resp.WriteBool(false) @@ -283,15 +280,15 @@ func handleMsgMhfContractMercenary(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfContractMercenary) switch pkt.Op { case 0: // Form loan - if _, err := s.server.db.Exec("UPDATE characters SET pact_id=$1 WHERE id=$2", pkt.PactMercID, pkt.CID); err != nil { + if err := s.server.charRepo.SaveInt(pkt.CID, "pact_id", int(pkt.PactMercID)); err != nil { s.logger.Error("Failed to form mercenary loan", zap.Error(err)) } case 1: // Cancel lend - if _, err := s.server.db.Exec("UPDATE characters SET pact_id=0 WHERE id=$1", s.charID); err != nil { + if err := s.server.charRepo.SaveInt(s.charID, "pact_id", 0); err != nil { s.logger.Error("Failed to cancel mercenary lend", zap.Error(err)) } case 2: // Cancel loan - if _, err := s.server.db.Exec("UPDATE characters SET pact_id=0 WHERE id=$1", pkt.CID); err != nil { + if err := s.server.charRepo.SaveInt(pkt.CID, "pact_id", 0); err != nil { s.logger.Error("Failed to cancel mercenary loan", zap.Error(err)) } } @@ -350,7 +347,7 @@ func handleMsgMhfSaveOtomoAirou(s *Session, p mhfpacket.MHFPacket) { s.logger.Error("Failed to compress airou", zap.Error(err)) } else { comp = append([]byte{0x01}, comp...) - if _, err := s.server.db.Exec("UPDATE characters SET otomoairou=$1 WHERE id=$2", comp, s.charID); err != nil { + if err := s.server.charRepo.SaveColumn(s.charID, "otomoairou", comp); err != nil { s.logger.Error("Failed to save otomoairou", zap.Error(err)) } } diff --git a/server/channelserver/handlers_misc.go b/server/channelserver/handlers_misc.go index 5d96c4481..f5feb7531 100644 --- a/server/channelserver/handlers_misc.go +++ b/server/channelserver/handlers_misc.go @@ -13,16 +13,15 @@ import ( func handleMsgMhfGetEtcPoints(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetEtcPoints) - var dailyTime time.Time - _ = s.server.db.QueryRow("SELECT COALESCE(daily_time, $2) FROM characters WHERE id = $1", s.charID, time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)).Scan(&dailyTime) + dailyTime, _ := s.server.charRepo.ReadTime(s.charID, "daily_time", time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)) if TimeAdjusted().After(dailyTime) { - if _, err := s.server.db.Exec("UPDATE characters SET bonus_quests = 0, daily_quests = 0 WHERE id=$1", s.charID); err != nil { + if err := s.server.charRepo.ResetDailyQuests(s.charID); err != nil { s.logger.Error("Failed to reset daily quests", zap.Error(err)) } } - var bonusQuests, dailyQuests, promoPoints uint32 - if err := s.server.db.QueryRow(`SELECT bonus_quests, daily_quests, promo_points FROM characters WHERE id = $1`, s.charID).Scan(&bonusQuests, &dailyQuests, &promoPoints); err != nil { + bonusQuests, dailyQuests, promoPoints, err := s.server.charRepo.ReadEtcPoints(s.charID) + if err != nil { s.logger.Error("Failed to get etc points", zap.Error(err)) } resp := byteframe.NewByteFrame() @@ -52,7 +51,7 @@ func handleMsgMhfUpdateEtcPoint(s *Session, p mhfpacket.MHFPacket) { value, err := readCharacterInt(s, column) if err == nil { newVal := max(value+int(pkt.Delta), 0) - if _, err := s.server.db.Exec("UPDATE characters SET "+column+"=$1 WHERE id=$2", newVal, s.charID); err != nil { + if err := s.server.charRepo.SaveInt(s.charID, column, newVal); err != nil { s.logger.Error("Failed to update etc point", zap.Error(err)) } } @@ -178,8 +177,7 @@ func handleMsgMhfGetEquipSkinHist(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfUpdateEquipSkinHist(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfUpdateEquipSkinHist) size := equipSkinHistSize(s.server.erupeConfig.RealClientMode) - var data []byte - err := s.server.db.QueryRow("SELECT COALESCE(skin_hist, $2) FROM characters WHERE id = $1", s.charID, make([]byte, size)).Scan(&data) + data, err := s.server.charRepo.LoadColumnWithDefault(s.charID, "skin_hist", make([]byte, size)) if err != nil { s.logger.Error("Failed to get skin_hist", zap.Error(err)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) @@ -201,7 +199,7 @@ func handleMsgMhfUpdateEquipSkinHist(s *Session, p mhfpacket.MHFPacket) { bitInByte := bit % 8 data[startByte+byteInd] |= bits.Reverse8(1 << uint(bitInByte)) dumpSaveData(s, data, "skinhist") - if _, err := s.server.db.Exec("UPDATE characters SET skin_hist=$1 WHERE id=$2", data, s.charID); err != nil { + if err := s.server.charRepo.SaveColumn(s.charID, "skin_hist", data); err != nil { s.logger.Error("Failed to update skin history", zap.Error(err)) } doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) diff --git a/server/channelserver/handlers_plate.go b/server/channelserver/handlers_plate.go index 1a548e653..1f417c9f3 100644 --- a/server/channelserver/handlers_plate.go +++ b/server/channelserver/handlers_plate.go @@ -64,7 +64,7 @@ func handleMsgMhfSavePlateData(s *Session, p mhfpacket.MHFPacket) { var data []byte // Load existing save - err := s.server.db.QueryRow("SELECT platedata FROM characters WHERE id = $1", s.charID).Scan(&data) + data, err := s.server.charRepo.LoadColumn(s.charID, "platedata") if err != nil { s.logger.Error("Failed to load platedata", zap.Error(err), @@ -104,7 +104,7 @@ func handleMsgMhfSavePlateData(s *Session, p mhfpacket.MHFPacket) { } dataSize = len(saveOutput) - _, err = s.server.db.Exec("UPDATE characters SET platedata=$1 WHERE id=$2", saveOutput, s.charID) + err = s.server.charRepo.SaveColumn(s.charID, "platedata", saveOutput) if err != nil { s.logger.Error("Failed to save platedata", zap.Error(err), @@ -118,7 +118,7 @@ func handleMsgMhfSavePlateData(s *Session, p mhfpacket.MHFPacket) { dataSize = len(pkt.RawDataPayload) // simply update database, no extra processing - _, err := s.server.db.Exec("UPDATE characters SET platedata=$1 WHERE id=$2", pkt.RawDataPayload, s.charID) + err := s.server.charRepo.SaveColumn(s.charID, "platedata", pkt.RawDataPayload) if err != nil { s.logger.Error("Failed to save platedata", zap.Error(err), @@ -164,7 +164,7 @@ func handleMsgMhfSavePlateBox(s *Session, p mhfpacket.MHFPacket) { var data []byte // Load existing save - err := s.server.db.QueryRow("SELECT platebox FROM characters WHERE id = $1", s.charID).Scan(&data) + data, err := s.server.charRepo.LoadColumn(s.charID, "platebox") if err != nil { s.logger.Error("Failed to load platebox", zap.Error(err)) doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00}) @@ -195,7 +195,7 @@ func handleMsgMhfSavePlateBox(s *Session, p mhfpacket.MHFPacket) { return } - _, err = s.server.db.Exec("UPDATE characters SET platebox=$1 WHERE id=$2", saveOutput, s.charID) + err = s.server.charRepo.SaveColumn(s.charID, "platebox", saveOutput) if err != nil { s.logger.Error("Failed to save platebox", zap.Error(err)) doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00}) @@ -206,7 +206,7 @@ func handleMsgMhfSavePlateBox(s *Session, p mhfpacket.MHFPacket) { } else { dumpSaveData(s, pkt.RawDataPayload, "platebox") // simply update database, no extra processing - _, err := s.server.db.Exec("UPDATE characters SET platebox=$1 WHERE id=$2", pkt.RawDataPayload, s.charID) + err := s.server.charRepo.SaveColumn(s.charID, "platebox", pkt.RawDataPayload) if err != nil { s.logger.Error("Failed to save platebox", zap.Error(err)) } @@ -242,7 +242,7 @@ func handleMsgMhfSavePlateMyset(s *Session, p mhfpacket.MHFPacket) { // looks to always return the full thing, simply update database, no extra processing dumpSaveData(s, pkt.RawDataPayload, "platemyset") - _, err := s.server.db.Exec("UPDATE characters SET platemyset=$1 WHERE id=$2", pkt.RawDataPayload, s.charID) + err := s.server.charRepo.SaveColumn(s.charID, "platemyset", pkt.RawDataPayload) if err != nil { s.logger.Error("Failed to save platemyset", zap.Error(err), diff --git a/server/channelserver/handlers_rengoku.go b/server/channelserver/handlers_rengoku.go index d34ff71eb..41b2978f8 100644 --- a/server/channelserver/handlers_rengoku.go +++ b/server/channelserver/handlers_rengoku.go @@ -86,8 +86,8 @@ func handleMsgMhfSaveRengokuData(s *Session, p mhfpacket.MHFPacket) { // the character data area. This produces a save with zeroed skill fields but // preserved point totals. Detect this pattern and merge existing skill data. if len(saveData) >= rengokuPointsEnd && rengokuSkillsZeroed(saveData) && rengokuHasPoints(saveData) { - var existing []byte - if err := s.server.db.QueryRow("SELECT rengokudata FROM characters WHERE id=$1", s.charID).Scan(&existing); err == nil { + existing, err := s.server.charRepo.LoadColumn(s.charID, "rengokudata") + if err == nil { if len(existing) >= rengokuPointsEnd && !rengokuSkillsZeroed(existing) { s.logger.Info("Rengoku save has zeroed skills with invested points, preserving existing skills", zap.Uint32("charID", s.charID)) @@ -101,8 +101,8 @@ func handleMsgMhfSaveRengokuData(s *Session, p mhfpacket.MHFPacket) { // Also reject saves where the sentinel is 0 (no data) if valid data already exists. if len(saveData) >= 4 && binary.BigEndian.Uint32(saveData[:4]) == 0 { - var existing []byte - if err := s.server.db.QueryRow("SELECT rengokudata FROM characters WHERE id=$1", s.charID).Scan(&existing); err == nil { + existing, err := s.server.charRepo.LoadColumn(s.charID, "rengokudata") + if err == nil { if len(existing) >= 4 && binary.BigEndian.Uint32(existing[:4]) != 0 { s.logger.Warn("Refusing to overwrite valid rengoku data with empty sentinel", zap.Uint32("charID", s.charID)) @@ -112,7 +112,7 @@ func handleMsgMhfSaveRengokuData(s *Session, p mhfpacket.MHFPacket) { } } - _, err := s.server.db.Exec("UPDATE characters SET rengokudata=$1 WHERE id=$2", saveData, s.charID) + err := s.server.charRepo.SaveColumn(s.charID, "rengokudata", saveData) if err != nil { s.logger.Error("Failed to save rengokudata", zap.Error(err)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) @@ -140,8 +140,7 @@ func handleMsgMhfSaveRengokuData(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfLoadRengokuData(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfLoadRengokuData) - var data []byte - err := s.server.db.QueryRow("SELECT rengokudata FROM characters WHERE id = $1", s.charID).Scan(&data) + data, err := s.server.charRepo.LoadColumn(s.charID, "rengokudata") if err != nil { s.logger.Error("Failed to load rengokudata", zap.Error(err), zap.Uint32("charID", s.charID)) diff --git a/server/channelserver/handlers_session.go b/server/channelserver/handlers_session.go index 3ec51e08d..f7ef7d87c 100644 --- a/server/channelserver/handlers_session.go +++ b/server/channelserver/handlers_session.go @@ -250,7 +250,7 @@ func logoutPlayer(s *Session) { if mhfcourse.CourseExists(30, s.courses) { rpGained = timePlayed / rpAccrualCafe timePlayed = timePlayed % rpAccrualCafe - if _, err := s.server.db.Exec("UPDATE characters SET cafe_time=cafe_time+$1 WHERE id=$2", sessionTime, s.charID); err != nil { + if _, err := s.server.charRepo.AdjustInt(s.charID, "cafe_time", sessionTime); err != nil { s.logger.Error("Failed to update cafe time", zap.Error(err)) } } else { diff --git a/server/channelserver/repo_character.go b/server/channelserver/repo_character.go index 3d0bf5b01..692c13e18 100644 --- a/server/channelserver/repo_character.go +++ b/server/channelserver/repo_character.go @@ -1,6 +1,11 @@ package channelserver -import "github.com/jmoiron/sqlx" +import ( + "database/sql" + "time" + + "github.com/jmoiron/sqlx" +) // CharacterRepository centralizes all database access for the characters table. type CharacterRepository struct { @@ -74,3 +79,134 @@ func (r *CharacterRepository) GetCharIDsByUserID(userID uint32) ([]uint32, error 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 rasta_id atomically. +func (r *CharacterRepository) SaveMercenary(charID uint32, data []byte, rastaID uint32) error { + _, 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 +} diff --git a/server/channelserver/repo_character_test.go b/server/channelserver/repo_character_test.go index af9bbf11c..ed7024992 100644 --- a/server/channelserver/repo_character_test.go +++ b/server/channelserver/repo_character_test.go @@ -2,6 +2,7 @@ package channelserver import ( "testing" + "time" "github.com/jmoiron/sqlx" ) @@ -221,3 +222,360 @@ func TestGetCharIDsByUserIDEmpty(t *testing.T) { t.Errorf("Expected 0 character IDs for user with no chars, got: %d", len(ids)) } } + +func TestReadTimeNull(t *testing.T) { + repo, _, charID := setupCharRepo(t) + + defaultTime := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + got, err := repo.ReadTime(charID, "daily_time", defaultTime) + if err != nil { + t.Fatalf("ReadTime failed: %v", err) + } + if !got.Equal(defaultTime) { + t.Errorf("Expected default time %v, got: %v", defaultTime, got) + } +} + +func TestReadTimeWithValue(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + expected := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + if _, err := db.Exec("UPDATE characters SET daily_time=$1 WHERE id=$2", expected, charID); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + got, err := repo.ReadTime(charID, "daily_time", time.Time{}) + if err != nil { + t.Fatalf("ReadTime failed: %v", err) + } + if !got.Equal(expected) { + t.Errorf("Expected %v, got: %v", expected, got) + } +} + +func TestSaveTime(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + expected := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + if err := repo.SaveTime(charID, "daily_time", expected); err != nil { + t.Fatalf("SaveTime failed: %v", err) + } + + var got time.Time + if err := db.QueryRow("SELECT daily_time FROM characters WHERE id=$1", charID).Scan(&got); err != nil { + t.Fatalf("Verification query failed: %v", err) + } + if !got.Equal(expected) { + t.Errorf("Expected %v, got: %v", expected, got) + } +} + +func TestSaveInt(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + if err := repo.SaveInt(charID, "netcafe_points", 500); err != nil { + t.Fatalf("SaveInt failed: %v", err) + } + + var got int + if err := db.QueryRow("SELECT netcafe_points FROM characters WHERE id=$1", charID).Scan(&got); err != nil { + t.Fatalf("Verification query failed: %v", err) + } + if got != 500 { + t.Errorf("Expected 500, got: %d", got) + } +} + +func TestSaveBool(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + if err := repo.SaveBool(charID, "restrict_guild_scout", true); err != nil { + t.Fatalf("SaveBool failed: %v", err) + } + + var got bool + if err := db.QueryRow("SELECT restrict_guild_scout FROM characters WHERE id=$1", charID).Scan(&got); err != nil { + t.Fatalf("Verification query failed: %v", err) + } + if !got { + t.Errorf("Expected true, got false") + } +} + +func TestReadBool(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + if _, err := db.Exec("UPDATE characters SET restrict_guild_scout=true WHERE id=$1", charID); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + got, err := repo.ReadBool(charID, "restrict_guild_scout") + if err != nil { + t.Fatalf("ReadBool failed: %v", err) + } + if !got { + t.Errorf("Expected true, got false") + } +} + +func TestSaveString(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + if err := repo.SaveString(charID, "friends", "1,2,3"); err != nil { + t.Fatalf("SaveString failed: %v", err) + } + + var got string + if err := db.QueryRow("SELECT friends FROM characters WHERE id=$1", charID).Scan(&got); err != nil { + t.Fatalf("Verification query failed: %v", err) + } + if got != "1,2,3" { + t.Errorf("Expected '1,2,3', got: %q", got) + } +} + +func TestReadString(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + if _, err := db.Exec("UPDATE characters SET friends='4,5,6' WHERE id=$1", charID); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + got, err := repo.ReadString(charID, "friends") + if err != nil { + t.Fatalf("ReadString failed: %v", err) + } + if got != "4,5,6" { + t.Errorf("Expected '4,5,6', got: %q", got) + } +} + +func TestReadStringNull(t *testing.T) { + repo, _, charID := setupCharRepo(t) + + got, err := repo.ReadString(charID, "friends") + if err != nil { + t.Fatalf("ReadString failed: %v", err) + } + if got != "" { + t.Errorf("Expected empty string for NULL, got: %q", got) + } +} + +func TestLoadColumnWithDefault(t *testing.T) { + repo, _, charID := setupCharRepo(t) + + defaultVal := []byte{0x00, 0x01, 0x02} + got, err := repo.LoadColumnWithDefault(charID, "skin_hist", defaultVal) + if err != nil { + t.Fatalf("LoadColumnWithDefault failed: %v", err) + } + if len(got) != 3 || got[0] != 0x00 || got[2] != 0x02 { + t.Errorf("Expected default value, got: %x", got) + } +} + +func TestLoadColumnWithDefaultExistingData(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + blob := []byte{0xAA, 0xBB} + if _, err := db.Exec("UPDATE characters SET skin_hist=$1 WHERE id=$2", blob, charID); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + got, err := repo.LoadColumnWithDefault(charID, "skin_hist", []byte{0x00}) + if err != nil { + t.Fatalf("LoadColumnWithDefault failed: %v", err) + } + if len(got) != 2 || got[0] != 0xAA || got[1] != 0xBB { + t.Errorf("Expected stored data, got: %x", got) + } +} + +func TestSetDeleted(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + if err := repo.SetDeleted(charID); err != nil { + t.Fatalf("SetDeleted failed: %v", err) + } + + var deleted bool + if err := db.QueryRow("SELECT deleted FROM characters WHERE id=$1", charID).Scan(&deleted); err != nil { + t.Fatalf("Verification query failed: %v", err) + } + if !deleted { + t.Errorf("Expected deleted=true") + } +} + +func TestUpdateDailyCafe(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + dailyTime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + if err := repo.UpdateDailyCafe(charID, dailyTime, 5, 10); err != nil { + t.Fatalf("UpdateDailyCafe failed: %v", err) + } + + var gotTime time.Time + var bonus, daily uint32 + if err := db.QueryRow("SELECT daily_time, bonus_quests, daily_quests FROM characters WHERE id=$1", charID).Scan(&gotTime, &bonus, &daily); err != nil { + t.Fatalf("Verification query failed: %v", err) + } + if !gotTime.Equal(dailyTime) { + t.Errorf("Expected daily_time %v, got: %v", dailyTime, gotTime) + } + if bonus != 5 || daily != 10 { + t.Errorf("Expected bonus=5 daily=10, got bonus=%d daily=%d", bonus, daily) + } +} + +func TestResetDailyQuests(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + if _, err := db.Exec("UPDATE characters SET bonus_quests=5, daily_quests=10 WHERE id=$1", charID); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + if err := repo.ResetDailyQuests(charID); err != nil { + t.Fatalf("ResetDailyQuests failed: %v", err) + } + + var bonus, daily uint32 + if err := db.QueryRow("SELECT bonus_quests, daily_quests FROM characters WHERE id=$1", charID).Scan(&bonus, &daily); err != nil { + t.Fatalf("Verification query failed: %v", err) + } + if bonus != 0 || daily != 0 { + t.Errorf("Expected bonus=0 daily=0, got bonus=%d daily=%d", bonus, daily) + } +} + +func TestReadEtcPoints(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + if _, err := db.Exec("UPDATE characters SET bonus_quests=3, daily_quests=7, promo_points=100 WHERE id=$1", charID); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + bonus, daily, promo, err := repo.ReadEtcPoints(charID) + if err != nil { + t.Fatalf("ReadEtcPoints failed: %v", err) + } + if bonus != 3 || daily != 7 || promo != 100 { + t.Errorf("Expected 3/7/100, got %d/%d/%d", bonus, daily, promo) + } +} + +func TestResetCafeTime(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + if _, err := db.Exec("UPDATE characters SET cafe_time=999 WHERE id=$1", charID); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + cafeReset := time.Date(2025, 6, 22, 0, 0, 0, 0, time.UTC) + if err := repo.ResetCafeTime(charID, cafeReset); err != nil { + t.Fatalf("ResetCafeTime failed: %v", err) + } + + var cafeTime int + var gotReset time.Time + if err := db.QueryRow("SELECT cafe_time, cafe_reset FROM characters WHERE id=$1", charID).Scan(&cafeTime, &gotReset); err != nil { + t.Fatalf("Verification query failed: %v", err) + } + if cafeTime != 0 { + t.Errorf("Expected cafe_time=0, got: %d", cafeTime) + } + if !gotReset.Equal(cafeReset) { + t.Errorf("Expected cafe_reset %v, got: %v", cafeReset, gotReset) + } +} + +func TestUpdateGuildPostChecked(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + before := time.Now().Add(-time.Second) + if err := repo.UpdateGuildPostChecked(charID); err != nil { + t.Fatalf("UpdateGuildPostChecked failed: %v", err) + } + + var got time.Time + if err := db.QueryRow("SELECT guild_post_checked FROM characters WHERE id=$1", charID).Scan(&got); err != nil { + t.Fatalf("Verification query failed: %v", err) + } + if got.Before(before) { + t.Errorf("Expected guild_post_checked to be recent, got: %v", got) + } +} + +func TestReadGuildPostChecked(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + expected := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + if _, err := db.Exec("UPDATE characters SET guild_post_checked=$1 WHERE id=$2", expected, charID); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + got, err := repo.ReadGuildPostChecked(charID) + if err != nil { + t.Fatalf("ReadGuildPostChecked failed: %v", err) + } + if !got.Equal(expected) { + t.Errorf("Expected %v, got: %v", expected, got) + } +} + +func TestSaveMercenary(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + data := []byte{0x01, 0x02, 0x03, 0x04} + if err := repo.SaveMercenary(charID, data, 42); err != nil { + t.Fatalf("SaveMercenary failed: %v", err) + } + + var gotData []byte + var gotRastaID uint32 + if err := db.QueryRow("SELECT savemercenary, rasta_id FROM characters WHERE id=$1", charID).Scan(&gotData, &gotRastaID); err != nil { + t.Fatalf("Verification query failed: %v", err) + } + if len(gotData) != 4 || gotData[0] != 0x01 { + t.Errorf("Expected mercenary data, got: %x", gotData) + } + if gotRastaID != 42 { + t.Errorf("Expected rasta_id=42, got: %d", gotRastaID) + } +} + +func TestUpdateGCPAndPact(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + if err := repo.UpdateGCPAndPact(charID, 100, 55); err != nil { + t.Fatalf("UpdateGCPAndPact failed: %v", err) + } + + var gcp, pactID uint32 + if err := db.QueryRow("SELECT gcp, pact_id FROM characters WHERE id=$1", charID).Scan(&gcp, &pactID); err != nil { + t.Fatalf("Verification query failed: %v", err) + } + if gcp != 100 || pactID != 55 { + t.Errorf("Expected gcp=100 pact_id=55, got gcp=%d pact_id=%d", gcp, pactID) + } +} + +func TestFindByRastaID(t *testing.T) { + repo, db, charID := setupCharRepo(t) + + if _, err := db.Exec("UPDATE characters SET rasta_id=999 WHERE id=$1", charID); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + gotID, gotName, err := repo.FindByRastaID(999) + if err != nil { + t.Fatalf("FindByRastaID failed: %v", err) + } + if gotID != charID { + t.Errorf("Expected charID %d, got: %d", charID, gotID) + } + if gotName != "RepoChar" { + t.Errorf("Expected 'RepoChar', got: %q", gotName) + } +}