refactor(channelserver): extract FestaRepository and TowerRepository

Move all direct DB calls from handlers_festa.go (23 calls across 8
tables) and handlers_tower.go (16 calls across 4 tables) into
dedicated repository structs following the established pattern.

FestaRepository (14 methods): lifecycle cleanup, event management,
team souls, trial stats/rankings, user state, voting, registration,
soul submission, prize claiming/enumeration.

TowerRepository (12 methods): personal tower data (skills, progress,
gems), guild tenrouirai progress/scores/page advancement, tower RP.

Also fix pre-existing nil pointer panics in integration tests by
adding SetTestDB helper that initializes both the DB connection and
all repositories, and wire the done channel in createTestServerWithDB
to prevent Shutdown panics.
This commit is contained in:
Houmgaor
2026-02-20 23:09:51 +01:00
parent a02251e486
commit b507057cc9
11 changed files with 516 additions and 202 deletions

View File

@@ -413,7 +413,7 @@ func TestGetCharacterSaveData_Integration(t *testing.T) {
mock := &MockCryptConn{sentPackets: make([][]byte, 0)} mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.server.db = db SetTestDB(s.server, db)
s.server.erupeConfig.RealClientMode = _config.Z2 s.server.erupeConfig.RealClientMode = _config.Z2
// Get character save data // Get character save data
@@ -457,7 +457,7 @@ func TestCharacterSaveData_Save_Integration(t *testing.T) {
mock := &MockCryptConn{sentPackets: make([][]byte, 0)} mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.server.db = db SetTestDB(s.server, db)
s.server.erupeConfig.RealClientMode = _config.Z2 s.server.erupeConfig.RealClientMode = _config.Z2
// Load character save data // Load character save data

View File

@@ -206,7 +206,7 @@ func TestHandleMsgMhfListMember_Integration(t *testing.T) {
mock := &MockCryptConn{sentPackets: make([][]byte, 0)} mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.server.db = db SetTestDB(s.server, db)
pkt := &mhfpacket.MsgMhfListMember{ pkt := &mhfpacket.MsgMhfListMember{
AckHandle: 5678, AckHandle: 5678,
@@ -313,7 +313,7 @@ func TestHandleMsgMhfOprMember_Integration(t *testing.T) {
mock := &MockCryptConn{sentPackets: make([][]byte, 0)} mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.server.db = db SetTestDB(s.server, db)
pkt := &mhfpacket.MsgMhfOprMember{ pkt := &mhfpacket.MsgMhfOprMember{
AckHandle: 9999, AckHandle: 9999,
@@ -452,7 +452,7 @@ func TestListMember_EmptyDatabase_Integration(t *testing.T) {
mock := &MockCryptConn{sentPackets: make([][]byte, 0)} mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.server.db = db SetTestDB(s.server, db)
pkt := &mhfpacket.MsgMhfListMember{ pkt := &mhfpacket.MsgMhfListMember{
AckHandle: 4444, AckHandle: 4444,
@@ -528,7 +528,7 @@ func TestOprMember_EdgeCases_Integration(t *testing.T) {
mock := &MockCryptConn{sentPackets: make([][]byte, 0)} mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.server.db = db SetTestDB(s.server, db)
pkt := &mhfpacket.MsgMhfOprMember{ pkt := &mhfpacket.MsgMhfOprMember{
AckHandle: 7777, AckHandle: 7777,

View File

@@ -357,7 +357,7 @@ func TestHandleMsgMhfSavedata_Integration(t *testing.T) {
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.Name = "TestChar" s.Name = "TestChar"
s.server.db = db SetTestDB(s.server, db)
tests := []struct { tests := []struct {
name string name string
@@ -442,7 +442,7 @@ func TestHandleMsgMhfLoaddata_Integration(t *testing.T) {
mock := &MockCryptConn{sentPackets: make([][]byte, 0)} mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.server.db = db SetTestDB(s.server, db)
s.server.userBinaryParts = make(map[userBinaryPartID][]byte) s.server.userBinaryParts = make(map[userBinaryPartID][]byte)
pkt := &mhfpacket.MsgMhfLoaddata{ pkt := &mhfpacket.MsgMhfLoaddata{
@@ -475,7 +475,7 @@ func TestHandleMsgMhfSaveScenarioData_Integration(t *testing.T) {
mock := &MockCryptConn{sentPackets: make([][]byte, 0)} mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.server.db = db SetTestDB(s.server, db)
scenarioData := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A} scenarioData := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A}
@@ -530,7 +530,7 @@ func TestHandleMsgMhfLoadScenarioData_Integration(t *testing.T) {
mock := &MockCryptConn{sentPackets: make([][]byte, 0)} mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.server.db = db SetTestDB(s.server, db)
pkt := &mhfpacket.MsgMhfLoadScenarioData{ pkt := &mhfpacket.MsgMhfLoadScenarioData{
AckHandle: 1111, AckHandle: 1111,
@@ -564,7 +564,7 @@ func TestSaveDataCorruptionDetection_Integration(t *testing.T) {
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.Name = "OriginalName" s.Name = "OriginalName"
s.server.db = db SetTestDB(s.server, db)
s.server.erupeConfig.DeleteOnSaveCorruption = false s.server.erupeConfig.DeleteOnSaveCorruption = false
// Create save data with a DIFFERENT name (corruption) // Create save data with a DIFFERENT name (corruption)
@@ -615,7 +615,7 @@ func TestConcurrentSaveData_Integration(t *testing.T) {
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charIDs[index] s.charID = charIDs[index]
s.Name = fmt.Sprintf("Char%d", index) s.Name = fmt.Sprintf("Char%d", index)
s.server.db = db SetTestDB(s.server, db)
saveData := make([]byte, 150000) saveData := make([]byte, 150000)
copy(saveData[88:], []byte(fmt.Sprintf("Char%d\x00", index))) copy(saveData[88:], []byte(fmt.Sprintf("Char%d\x00", index)))

View File

@@ -86,20 +86,8 @@ func handleMsgMhfEnumerateRanking(s *Session, p mhfpacket.MHFPacket) {
} }
func cleanupFesta(s *Session) { func cleanupFesta(s *Session) {
if _, err := s.server.db.Exec("DELETE FROM events WHERE event_type='festa'"); err != nil { if err := s.server.festaRepo.CleanupAll(); err != nil {
s.logger.Error("Failed to delete festa events", zap.Error(err)) s.logger.Error("Failed to cleanup festa", zap.Error(err))
}
if _, err := s.server.db.Exec("DELETE FROM festa_registrations"); err != nil {
s.logger.Error("Failed to delete festa registrations", zap.Error(err))
}
if _, err := s.server.db.Exec("DELETE FROM festa_submissions"); err != nil {
s.logger.Error("Failed to delete festa submissions", zap.Error(err))
}
if _, err := s.server.db.Exec("DELETE FROM festa_prizes_accepted"); err != nil {
s.logger.Error("Failed to delete festa prizes accepted", zap.Error(err))
}
if _, err := s.server.db.Exec("UPDATE guild_characters SET trial_vote=NULL"); err != nil {
s.logger.Error("Failed to reset festa trial votes", zap.Error(err))
} }
} }
@@ -141,7 +129,7 @@ func generateFestaTimestamps(s *Session, start uint32, debug bool) []uint32 {
cleanupFesta(s) cleanupFesta(s)
// Generate a new festa, starting midnight tomorrow // Generate a new festa, starting midnight tomorrow
start = uint32(midnight.Add(24 * time.Hour).Unix()) start = uint32(midnight.Add(24 * time.Hour).Unix())
if _, err := s.server.db.Exec("INSERT INTO events (event_type, start_time) VALUES ('festa', to_timestamp($1)::timestamp without time zone)", start); err != nil { if err := s.server.festaRepo.InsertEvent(start); err != nil {
s.logger.Error("Failed to insert festa event", zap.Error(err)) s.logger.Error("Failed to insert festa event", zap.Error(err))
} }
} }
@@ -183,13 +171,13 @@ func handleMsgMhfInfoFesta(s *Session, p mhfpacket.MHFPacket) {
const festaIDSentinel = uint32(0xDEADBEEF) const festaIDSentinel = uint32(0xDEADBEEF)
id, start := festaIDSentinel, uint32(0) id, start := festaIDSentinel, uint32(0)
rows, err := s.server.db.Queryx("SELECT id, (EXTRACT(epoch FROM start_time)::int) as start_time FROM events WHERE event_type='festa'") events, err := s.server.festaRepo.GetFestaEvents()
if err != nil { if err != nil {
s.logger.Error("Failed to query festa schedule", zap.Error(err)) s.logger.Error("Failed to query festa schedule", zap.Error(err))
} else { } else {
defer func() { _ = rows.Close() }() for _, e := range events {
for rows.Next() { id = e.ID
_ = rows.Scan(&id, &start) start = e.StartTime
} }
} }
@@ -209,11 +197,12 @@ func handleMsgMhfInfoFesta(s *Session, p mhfpacket.MHFPacket) {
return return
} }
var blueSouls, redSouls uint32 blueSouls, err := s.server.festaRepo.GetTeamSouls("blue")
if err := s.server.db.QueryRow(`SELECT COALESCE(SUM(fs.souls), 0) AS souls FROM festa_registrations fr LEFT JOIN festa_submissions fs ON fr.guild_id = fs.guild_id AND fr.team = 'blue'`).Scan(&blueSouls); err != nil { if err != nil {
s.logger.Error("Failed to get blue souls", zap.Error(err)) s.logger.Error("Failed to get blue souls", zap.Error(err))
} }
if err := s.server.db.QueryRow(`SELECT COALESCE(SUM(fs.souls), 0) AS souls FROM festa_registrations fr LEFT JOIN festa_submissions fs ON fr.guild_id = fs.guild_id AND fr.team = 'red'`).Scan(&redSouls); err != nil { redSouls, err := s.server.festaRepo.GetTeamSouls("red")
if err != nil {
s.logger.Error("Failed to get red souls", zap.Error(err)) s.logger.Error("Failed to get red souls", zap.Error(err))
} }
@@ -228,31 +217,9 @@ func handleMsgMhfInfoFesta(s *Session, p mhfpacket.MHFPacket) {
bf.WriteUint32(blueSouls) bf.WriteUint32(blueSouls)
bf.WriteUint32(redSouls) bf.WriteUint32(redSouls)
var trials []FestaTrial trials, err := s.server.festaRepo.GetTrialsWithMonopoly()
var trial FestaTrial
rows, err = s.server.db.Queryx(`SELECT ft.*,
COALESCE(CASE
WHEN COUNT(gc.id) FILTER (WHERE fr.team = 'blue' AND gc.trial_vote = ft.id) >
COUNT(gc.id) FILTER (WHERE fr.team = 'red' AND gc.trial_vote = ft.id)
THEN CAST('blue' AS public.festival_color)
WHEN COUNT(gc.id) FILTER (WHERE fr.team = 'red' AND gc.trial_vote = ft.id) >
COUNT(gc.id) FILTER (WHERE fr.team = 'blue' AND gc.trial_vote = ft.id)
THEN CAST('red' AS public.festival_color)
END, CAST('none' AS public.festival_color)) AS monopoly
FROM public.festa_trials ft
LEFT JOIN public.guild_characters gc ON ft.id = gc.trial_vote
LEFT JOIN public.festa_registrations fr ON gc.guild_id = fr.guild_id
GROUP BY ft.id`)
if err != nil { if err != nil {
s.logger.Error("Failed to query festa trials", zap.Error(err)) s.logger.Error("Failed to query festa trials", zap.Error(err))
} else {
defer func() { _ = rows.Close() }()
for rows.Next() {
if err := rows.StructScan(&trial); err != nil {
continue
}
trials = append(trials, trial)
}
} }
bf.WriteUint16(uint16(len(trials))) bf.WriteUint16(uint16(len(trials)))
for _, trial := range trials { for _, trial := range trials {
@@ -323,49 +290,28 @@ func handleMsgMhfInfoFesta(s *Session, p mhfpacket.MHFPacket) {
} }
bf.WriteUint16(100) // Reward multiplier (%) bf.WriteUint16(100) // Reward multiplier (%)
var temp uint32
bf.WriteUint16(4) bf.WriteUint16(4)
for i := uint16(0); i < 4; i++ { for i := uint16(0); i < 4; i++ {
var guildID uint32 ranking, err := s.server.festaRepo.GetTopGuildForTrial(i + 1)
var guildName string if err != nil && !errors.Is(err, sql.ErrNoRows) {
var guildTeam = FestivalColorNone
if err := s.server.db.QueryRow(`
SELECT fs.guild_id, g.name, fr.team, SUM(fs.souls) as _
FROM festa_submissions fs
LEFT JOIN festa_registrations fr ON fs.guild_id = fr.guild_id
LEFT JOIN guilds g ON fs.guild_id = g.id
WHERE fs.trial_type = $1
GROUP BY fs.guild_id, g.name, fr.team
ORDER BY _ DESC LIMIT 1
`, i+1).Scan(&guildID, &guildName, &guildTeam, &temp); err != nil && !errors.Is(err, sql.ErrNoRows) {
s.logger.Error("Failed to get festa trial ranking", zap.Error(err)) s.logger.Error("Failed to get festa trial ranking", zap.Error(err))
} }
bf.WriteUint32(guildID) bf.WriteUint32(ranking.GuildID)
bf.WriteUint16(i + 1) bf.WriteUint16(i + 1)
bf.WriteInt16(FestivalColorCodes[guildTeam]) bf.WriteInt16(FestivalColorCodes[ranking.Team])
ps.Uint8(bf, guildName, true) ps.Uint8(bf, ranking.GuildName, true)
} }
bf.WriteUint16(7) bf.WriteUint16(7)
for i := uint16(0); i < 7; i++ { for i := uint16(0); i < 7; i++ {
var guildID uint32
var guildName string
var guildTeam = FestivalColorNone
offset := secsPerDay * uint32(i) offset := secsPerDay * uint32(i)
if err := s.server.db.QueryRow(` ranking, err := s.server.festaRepo.GetTopGuildInWindow(timestamps[1]+offset, timestamps[1]+offset+secsPerDay)
SELECT fs.guild_id, g.name, fr.team, SUM(fs.souls) as _ if err != nil && !errors.Is(err, sql.ErrNoRows) {
FROM festa_submissions fs
LEFT JOIN festa_registrations fr ON fs.guild_id = fr.guild_id
LEFT JOIN guilds g ON fs.guild_id = g.id
WHERE EXTRACT(EPOCH FROM fs.timestamp)::int > $1 AND EXTRACT(EPOCH FROM fs.timestamp)::int < $2
GROUP BY fs.guild_id, g.name, fr.team
ORDER BY _ DESC LIMIT 1
`, timestamps[1]+offset, timestamps[1]+offset+secsPerDay).Scan(&guildID, &guildName, &guildTeam, &temp); err != nil && !errors.Is(err, sql.ErrNoRows) {
s.logger.Error("Failed to get festa daily ranking", zap.Error(err)) s.logger.Error("Failed to get festa daily ranking", zap.Error(err))
} }
bf.WriteUint32(guildID) bf.WriteUint32(ranking.GuildID)
bf.WriteUint16(i + 1) bf.WriteUint16(i + 1)
bf.WriteInt16(FestivalColorCodes[guildTeam]) bf.WriteInt16(FestivalColorCodes[ranking.Team])
ps.Uint8(bf, guildName, true) ps.Uint8(bf, ranking.GuildName, true)
} }
bf.WriteUint32(0) // Clan goal bf.WriteUint32(0) // Clan goal
@@ -398,14 +344,14 @@ func handleMsgMhfStateFestaU(s *Session, p mhfpacket.MHFPacket) {
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4)) doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
return return
} }
var souls, exists uint32 souls, err := s.server.festaRepo.GetCharSouls(s.charID)
if err := s.server.db.QueryRow(`SELECT COALESCE((SELECT SUM(souls) FROM festa_submissions WHERE character_id=$1), 0)`, s.charID).Scan(&souls); err != nil { if err != nil {
s.logger.Error("Failed to get festa user souls", zap.Error(err)) s.logger.Error("Failed to get festa user souls", zap.Error(err))
} }
err = s.server.db.QueryRow("SELECT prize_id FROM festa_prizes_accepted WHERE prize_id=0 AND character_id=$1", s.charID).Scan(&exists) claimed := s.server.festaRepo.HasClaimedMainPrize(s.charID)
bf := byteframe.NewByteFrame() bf := byteframe.NewByteFrame()
bf.WriteUint32(souls) bf.WriteUint32(souls)
if err != nil { if !claimed {
bf.WriteBool(true) bf.WriteBool(true)
bf.WriteBool(false) bf.WriteBool(false)
} else { } else {
@@ -479,7 +425,7 @@ func handleMsgMhfEnumerateFestaMember(s *Session, p mhfpacket.MHFPacket) {
func handleMsgMhfVoteFesta(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfVoteFesta(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfVoteFesta) pkt := p.(*mhfpacket.MsgMhfVoteFesta)
if _, err := s.server.db.Exec(`UPDATE guild_characters SET trial_vote=$1 WHERE character_id=$2`, pkt.TrialID, s.charID); err != nil { if err := s.server.festaRepo.VoteTrial(s.charID, pkt.TrialID); err != nil {
s.logger.Error("Failed to update festa trial vote", zap.Error(err)) s.logger.Error("Failed to update festa trial vote", zap.Error(err))
} }
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
@@ -493,15 +439,12 @@ func handleMsgMhfEntryFesta(s *Session, p mhfpacket.MHFPacket) {
return return
} }
team := uint32(token.RNG.Intn(2)) team := uint32(token.RNG.Intn(2))
switch team { teamName := "blue"
case 0: if team == 1 {
if _, err := s.server.db.Exec("INSERT INTO festa_registrations VALUES ($1, 'blue')", guild.ID); err != nil { teamName = "red"
s.logger.Error("Failed to register guild for festa blue team", zap.Error(err)) }
} if err := s.server.festaRepo.RegisterGuild(guild.ID, teamName); err != nil {
case 1: s.logger.Error("Failed to register guild for festa", zap.Error(err))
if _, err := s.server.db.Exec("INSERT INTO festa_registrations VALUES ($1, 'red')", guild.ID); err != nil {
s.logger.Error("Failed to register guild for festa red team", zap.Error(err))
}
} }
bf := byteframe.NewByteFrame() bf := byteframe.NewByteFrame()
bf.WriteUint32(team) bf.WriteUint32(team)
@@ -510,28 +453,15 @@ func handleMsgMhfEntryFesta(s *Session, p mhfpacket.MHFPacket) {
func handleMsgMhfChargeFesta(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfChargeFesta(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfChargeFesta) pkt := p.(*mhfpacket.MsgMhfChargeFesta)
tx, err := s.server.db.Begin() if err := s.server.festaRepo.SubmitSouls(s.charID, pkt.GuildID, pkt.Souls); err != nil {
if err != nil { s.logger.Error("Failed to submit festa souls", zap.Error(err))
s.logger.Error("Failed to begin festa submission transaction", zap.Error(err))
} else {
for i := range pkt.Souls {
if pkt.Souls[i] == 0 {
continue
}
if _, err := tx.Exec(`INSERT INTO festa_submissions VALUES ($1, $2, $3, $4, now())`, s.charID, pkt.GuildID, i, pkt.Souls[i]); err != nil {
s.logger.Error("Failed to insert festa submission", zap.Error(err))
}
}
if err := tx.Commit(); err != nil {
s.logger.Error("Failed to commit festa submissions", zap.Error(err))
}
} }
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
} }
func handleMsgMhfAcquireFesta(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfAcquireFesta(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfAcquireFesta) pkt := p.(*mhfpacket.MsgMhfAcquireFesta)
if _, err := s.server.db.Exec("INSERT INTO public.festa_prizes_accepted VALUES (0, $1)", s.charID); err != nil { if err := s.server.festaRepo.ClaimPrize(0, s.charID); err != nil {
s.logger.Error("Failed to accept festa prize", zap.Error(err)) s.logger.Error("Failed to accept festa prize", zap.Error(err))
} }
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
@@ -539,7 +469,7 @@ func handleMsgMhfAcquireFesta(s *Session, p mhfpacket.MHFPacket) {
func handleMsgMhfAcquireFestaPersonalPrize(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfAcquireFestaPersonalPrize(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfAcquireFestaPersonalPrize) pkt := p.(*mhfpacket.MsgMhfAcquireFestaPersonalPrize)
if _, err := s.server.db.Exec("INSERT INTO public.festa_prizes_accepted VALUES ($1, $2)", pkt.PrizeID, s.charID); err != nil { if err := s.server.festaRepo.ClaimPrize(pkt.PrizeID, s.charID); err != nil {
s.logger.Error("Failed to accept festa personal prize", zap.Error(err)) s.logger.Error("Failed to accept festa personal prize", zap.Error(err))
} }
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
@@ -547,7 +477,7 @@ func handleMsgMhfAcquireFestaPersonalPrize(s *Session, p mhfpacket.MHFPacket) {
func handleMsgMhfAcquireFestaIntermediatePrize(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfAcquireFestaIntermediatePrize(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfAcquireFestaIntermediatePrize) pkt := p.(*mhfpacket.MsgMhfAcquireFestaIntermediatePrize)
if _, err := s.server.db.Exec("INSERT INTO public.festa_prizes_accepted VALUES ($1, $2)", pkt.PrizeID, s.charID); err != nil { if err := s.server.festaRepo.ClaimPrize(pkt.PrizeID, s.charID); err != nil {
s.logger.Error("Failed to accept festa intermediate prize", zap.Error(err)) s.logger.Error("Failed to accept festa intermediate prize", zap.Error(err))
} }
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
@@ -563,20 +493,14 @@ type Prize struct {
Claimed int `db:"claimed"` Claimed int `db:"claimed"`
} }
func handleMsgMhfEnumerateFestaPersonalPrize(s *Session, p mhfpacket.MHFPacket) { func writePrizeList(s *Session, pkt mhfpacket.MHFPacket, ackHandle uint32, prizeType string) {
pkt := p.(*mhfpacket.MsgMhfEnumerateFestaPersonalPrize) prizes, err := s.server.festaRepo.ListPrizes(s.charID, prizeType)
rows, err := s.server.db.Queryx(`SELECT id, tier, souls_req, item_id, num_item, (SELECT count(*) FROM festa_prizes_accepted fpa WHERE fp.id = fpa.prize_id AND fpa.character_id = $1) AS claimed FROM festa_prizes fp WHERE type='personal'`, s.charID)
var count uint32 var count uint32
prizeData := byteframe.NewByteFrame() prizeData := byteframe.NewByteFrame()
if err != nil { if err != nil {
s.logger.Error("Failed to query festa personal prizes", zap.Error(err)) s.logger.Error("Failed to query festa prizes", zap.Error(err), zap.String("type", prizeType))
} else { } else {
defer func() { _ = rows.Close() }() for _, prize := range prizes {
for rows.Next() {
prize := &Prize{}
if err := rows.StructScan(&prize); err != nil {
continue
}
count++ count++
prizeData.WriteUint32(prize.ID) prizeData.WriteUint32(prize.ID)
prizeData.WriteUint32(prize.Tier) prizeData.WriteUint32(prize.Tier)
@@ -590,35 +514,15 @@ func handleMsgMhfEnumerateFestaPersonalPrize(s *Session, p mhfpacket.MHFPacket)
bf := byteframe.NewByteFrame() bf := byteframe.NewByteFrame()
bf.WriteUint32(count) bf.WriteUint32(count)
bf.WriteBytes(prizeData.Data()) bf.WriteBytes(prizeData.Data())
doAckBufSucceed(s, pkt.AckHandle, bf.Data()) doAckBufSucceed(s, ackHandle, bf.Data())
}
func handleMsgMhfEnumerateFestaPersonalPrize(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfEnumerateFestaPersonalPrize)
writePrizeList(s, p, pkt.AckHandle, "personal")
} }
func handleMsgMhfEnumerateFestaIntermediatePrize(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfEnumerateFestaIntermediatePrize(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfEnumerateFestaIntermediatePrize) pkt := p.(*mhfpacket.MsgMhfEnumerateFestaIntermediatePrize)
rows, err := s.server.db.Queryx(`SELECT id, tier, souls_req, item_id, num_item, (SELECT count(*) FROM festa_prizes_accepted fpa WHERE fp.id = fpa.prize_id AND fpa.character_id = $1) AS claimed FROM festa_prizes fp WHERE type='guild'`, s.charID) writePrizeList(s, p, pkt.AckHandle, "guild")
var count uint32
prizeData := byteframe.NewByteFrame()
if err != nil {
s.logger.Error("Failed to query festa intermediate prizes", zap.Error(err))
} else {
defer func() { _ = rows.Close() }()
for rows.Next() {
prize := &Prize{}
if err := rows.StructScan(&prize); err != nil {
continue
}
count++
prizeData.WriteUint32(prize.ID)
prizeData.WriteUint32(prize.Tier)
prizeData.WriteUint32(prize.SoulsReq)
prizeData.WriteUint32(7) // Unk
prizeData.WriteUint32(prize.ItemID)
prizeData.WriteUint32(prize.NumItem)
prizeData.WriteBool(prize.Claimed > 0)
}
}
bf := byteframe.NewByteFrame()
bf.WriteUint32(count)
bf.WriteBytes(prizeData.Data())
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
} }

View File

@@ -71,7 +71,7 @@ func TestSaveLoad_HunterNavi(t *testing.T) {
mock := &MockCryptConn{sentPackets: make([][]byte, 0)} mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.server.db = db SetTestDB(s.server, db)
// Create Hunter Navi data // Create Hunter Navi data
naviData := make([]byte, 552) // G8+ size naviData := make([]byte, 552) // G8+ size
@@ -117,7 +117,7 @@ func TestSaveLoad_MonsterKillCounter(t *testing.T) {
mock := &MockCryptConn{sentPackets: make([][]byte, 0)} mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.server.db = db SetTestDB(s.server, db)
// Initial Koryo points // Initial Koryo points
initialPoints := uint32(0) initialPoints := uint32(0)
@@ -255,7 +255,7 @@ func TestSaveLoad_CurrentEquipment(t *testing.T) {
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.Name = "TestChar" s.Name = "TestChar"
s.server.db = db SetTestDB(s.server, db)
// Create savedata with equipped gear // Create savedata with equipped gear
// Equipment data is embedded in the main savedata blob // Equipment data is embedded in the main savedata blob
@@ -369,7 +369,7 @@ func TestSaveLoad_Transmog(t *testing.T) {
mock := &MockCryptConn{sentPackets: make([][]byte, 0)} mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.server.db = db SetTestDB(s.server, db)
// Create valid transmog/decoration set data // Create valid transmog/decoration set data
// Format: [version byte][count byte][count * (uint16 index + setSize bytes)] // Format: [version byte][count byte][count * (uint16 index + setSize bytes)]
@@ -466,7 +466,7 @@ func TestSaveLoad_CompleteSaveLoadCycle(t *testing.T) {
s := createTestSession(mock) s := createTestSession(mock)
s.charID = charID s.charID = charID
s.Name = "SaveLoadTest" s.Name = "SaveLoadTest"
s.server.db = db SetTestDB(s.server, db)
// 1. Set Road Points // 1. Set Road Points
rdpPoints := uint32(5000) rdpPoints := uint32(5000)

View File

@@ -2,7 +2,6 @@ package channelserver
import ( import (
_config "erupe-ce/config" _config "erupe-ce/config"
"fmt"
"math" "math"
"strings" "strings"
"time" "time"
@@ -66,20 +65,22 @@ func handleMsgMhfGetTowerInfo(s *Session, p mhfpacket.MHFPacket) {
Level: []TowerInfoLevel{{0, 0, 0, 0}, {0, 0, 0, 0}}, Level: []TowerInfoLevel{{0, 0, 0, 0}, {0, 0, 0, 0}},
} }
var tempSkills string td, err := s.server.towerRepo.GetTowerData(s.charID)
err := s.server.db.QueryRow(`SELECT COALESCE(tr, 0), COALESCE(trp, 0), COALESCE(tsp, 0), COALESCE(block1, 0), COALESCE(block2, 0), COALESCE(skills, $1) FROM tower WHERE char_id=$2
`, EmptyTowerCSV(64), s.charID).Scan(&towerInfo.TRP[0].TR, &towerInfo.TRP[0].TRP, &towerInfo.Skill[0].TSP, &towerInfo.Level[0].Floors, &towerInfo.Level[1].Floors, &tempSkills)
if err != nil { if err != nil {
if _, err := s.server.db.Exec(`INSERT INTO tower (char_id) VALUES ($1)`, s.charID); err != nil { s.logger.Error("Failed to initialize tower data", zap.Error(err))
s.logger.Error("Failed to initialize tower data", zap.Error(err)) } else {
} towerInfo.TRP[0].TR = td.TR
towerInfo.TRP[0].TRP = td.TRP
towerInfo.Skill[0].TSP = td.TSP
towerInfo.Level[0].Floors = td.Block1
towerInfo.Level[1].Floors = td.Block2
} }
if s.server.erupeConfig.RealClientMode <= _config.G7 { if s.server.erupeConfig.RealClientMode <= _config.G7 {
towerInfo.Level = towerInfo.Level[:1] towerInfo.Level = towerInfo.Level[:1]
} }
for i, skill := range stringsupport.CSVElems(tempSkills) { for i, skill := range stringsupport.CSVElems(td.Skills) {
if skill < math.MinInt16 || skill > math.MaxInt16 { if skill < math.MinInt16 || skill > math.MaxInt16 {
continue continue
} }
@@ -148,14 +149,14 @@ func handleMsgMhfPostTowerInfo(s *Session, p mhfpacket.MHFPacket) {
switch pkt.InfoType { switch pkt.InfoType {
case 2: case 2:
var skills string skills, _ := s.server.towerRepo.GetSkills(s.charID)
_ = s.server.db.QueryRow(`SELECT COALESCE(skills, $1) FROM tower WHERE char_id=$2`, EmptyTowerCSV(64), s.charID).Scan(&skills) newSkills := stringsupport.CSVSetIndex(skills, int(pkt.Skill), stringsupport.CSVGetIndex(skills, int(pkt.Skill))+1)
if _, err := s.server.db.Exec(`UPDATE tower SET skills=$1, tsp=tsp-$2 WHERE char_id=$3`, stringsupport.CSVSetIndex(skills, int(pkt.Skill), stringsupport.CSVGetIndex(skills, int(pkt.Skill))+1), pkt.Cost, s.charID); err != nil { if err := s.server.towerRepo.UpdateSkills(s.charID, newSkills, pkt.Cost); err != nil {
s.logger.Error("Failed to update tower skills", zap.Error(err)) s.logger.Error("Failed to update tower skills", zap.Error(err))
} }
case 1, 7: case 1, 7:
// This might give too much TSP? No idea what the rate is supposed to be // This might give too much TSP? No idea what the rate is supposed to be
if _, err := s.server.db.Exec(`UPDATE tower SET tr=$1, trp=COALESCE(trp, 0)+$2, tsp=COALESCE(tsp, 0)+$3, block1=COALESCE(block1, 0)+$4 WHERE char_id=$5`, pkt.TR, pkt.TRP, pkt.Cost, pkt.Block1, s.charID); err != nil { if err := s.server.towerRepo.UpdateProgress(s.charID, pkt.TR, pkt.TRP, pkt.Cost, pkt.Block1); err != nil {
s.logger.Error("Failed to update tower progress", zap.Error(err)) s.logger.Error("Failed to update tower progress", zap.Error(err))
} }
} }
@@ -306,11 +307,15 @@ func handleMsgMhfGetTenrouirai(s *Session, p mhfpacket.MHFPacket) {
data = append(data, bf) data = append(data, bf)
} }
case 4: case 4:
if err := s.server.db.QueryRow(`SELECT tower_mission_page FROM guilds WHERE id=$1`, pkt.GuildID).Scan(&tenrouirai.Progress[0].Page); err != nil { progress, err := s.server.towerRepo.GetTenrouiraiProgress(pkt.GuildID)
if err != nil {
s.logger.Error("Failed to read tower mission page", zap.Error(err)) s.logger.Error("Failed to read tower mission page", zap.Error(err))
} else {
tenrouirai.Progress[0].Page = progress.Page
tenrouirai.Progress[0].Mission1 = progress.Mission1
tenrouirai.Progress[0].Mission2 = progress.Mission2
tenrouirai.Progress[0].Mission3 = progress.Mission3
} }
_ = s.server.db.QueryRow(`SELECT SUM(tower_mission_1) AS _, SUM(tower_mission_2) AS _, SUM(tower_mission_3) AS _ FROM guild_characters WHERE guild_id=$1
`, pkt.GuildID).Scan(&tenrouirai.Progress[0].Mission1, &tenrouirai.Progress[0].Mission2, &tenrouirai.Progress[0].Mission3)
if tenrouirai.Progress[0].Page < 1 { if tenrouirai.Progress[0].Page < 1 {
tenrouirai.Progress[0].Page = 1 tenrouirai.Progress[0].Page = 1
@@ -334,28 +339,19 @@ func handleMsgMhfGetTenrouirai(s *Session, p mhfpacket.MHFPacket) {
data = append(data, bf) data = append(data, bf)
} }
case 5: case 5:
if pkt.MissionIndex < 1 || pkt.MissionIndex > 3 { scores, err := s.server.towerRepo.GetTenrouiraiMissionScores(pkt.GuildID, pkt.MissionIndex)
pkt.MissionIndex = (pkt.MissionIndex % 3) + 1
}
rows, err := s.server.db.Query(fmt.Sprintf(`SELECT name, tower_mission_%d FROM guild_characters gc INNER JOIN characters c ON gc.character_id = c.id WHERE guild_id=$1 AND tower_mission_%d IS NOT NULL ORDER BY tower_mission_%d DESC`, pkt.MissionIndex, pkt.MissionIndex, pkt.MissionIndex), pkt.GuildID)
if err != nil { if err != nil {
s.logger.Error("Failed to query tower mission scores", zap.Error(err)) s.logger.Error("Failed to query tower mission scores", zap.Error(err))
} else {
defer func() { _ = rows.Close() }()
for rows.Next() {
temp := TenrouiraiCharScore{}
_ = rows.Scan(&temp.Name, &temp.Score)
tenrouirai.CharScore = append(tenrouirai.CharScore, temp)
}
} }
for _, charScore := range tenrouirai.CharScore { for _, charScore := range scores {
bf := byteframe.NewByteFrame() bf := byteframe.NewByteFrame()
bf.WriteInt32(charScore.Score) bf.WriteInt32(charScore.Score)
bf.WriteBytes(stringsupport.PaddedString(charScore.Name, 14, true)) bf.WriteBytes(stringsupport.PaddedString(charScore.Name, 14, true))
data = append(data, bf) data = append(data, bf)
} }
case 6: case 6:
_ = s.server.db.QueryRow(`SELECT tower_rp FROM guilds WHERE id=$1`, pkt.GuildID).Scan(&tenrouirai.Ticket[0].RP) rp, _ := s.server.towerRepo.GetGuildTowerRP(pkt.GuildID)
tenrouirai.Ticket[0].RP = rp
for _, ticket := range tenrouirai.Ticket { for _, ticket := range tenrouirai.Ticket {
bf := byteframe.NewByteFrame() bf := byteframe.NewByteFrame()
bf.WriteUint8(ticket.Unk0) bf.WriteUint8(ticket.Unk0)
@@ -388,13 +384,14 @@ func handleMsgMhfPostTenrouirai(s *Session, p mhfpacket.MHFPacket) {
} }
if pkt.Op == 2 { if pkt.Op == 2 {
var page, requirement, donated int page, donated, err := s.server.towerRepo.GetGuildTowerPageAndRP(pkt.GuildID)
if err := s.server.db.QueryRow(`SELECT tower_mission_page, tower_rp FROM guilds WHERE id=$1`, pkt.GuildID).Scan(&page, &donated); err != nil { if err != nil {
s.logger.Error("Failed to read guild tower state for donation", zap.Error(err)) s.logger.Error("Failed to read guild tower state for donation", zap.Error(err))
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
return return
} }
var requirement int
for i := 0; i < (page*3)+1; i++ { for i := 0; i < (page*3)+1; i++ {
requirement += int(tenrouiraiData[i].Cost) requirement += int(tenrouiraiData[i].Cost)
} }
@@ -406,16 +403,13 @@ func handleMsgMhfPostTenrouirai(s *Session, p mhfpacket.MHFPacket) {
sd.RP -= pkt.DonatedRP sd.RP -= pkt.DonatedRP
sd.Save(s) sd.Save(s)
if donated+int(pkt.DonatedRP) >= requirement { if donated+int(pkt.DonatedRP) >= requirement {
if _, err := s.server.db.Exec(`UPDATE guilds SET tower_mission_page=tower_mission_page+1 WHERE id=$1`, pkt.GuildID); err != nil { if err := s.server.towerRepo.AdvanceTenrouiraiPage(pkt.GuildID); err != nil {
s.logger.Error("Failed to advance tower mission page", zap.Error(err)) s.logger.Error("Failed to advance tower mission page", zap.Error(err))
} }
if _, err := s.server.db.Exec(`UPDATE guild_characters SET tower_mission_1=NULL, tower_mission_2=NULL, tower_mission_3=NULL WHERE guild_id=$1`, pkt.GuildID); err != nil {
s.logger.Error("Failed to reset tower mission progress", zap.Error(err))
}
pkt.DonatedRP = uint16(requirement - donated) pkt.DonatedRP = uint16(requirement - donated)
} }
bf.WriteUint32(uint32(pkt.DonatedRP)) bf.WriteUint32(uint32(pkt.DonatedRP))
if _, err := s.server.db.Exec(`UPDATE guilds SET tower_rp=tower_rp+$1 WHERE id=$2`, pkt.DonatedRP, pkt.GuildID); err != nil { if err := s.server.towerRepo.DonateGuildTowerRP(pkt.GuildID, pkt.DonatedRP); err != nil {
s.logger.Error("Failed to update guild tower RP", zap.Error(err)) s.logger.Error("Failed to update guild tower RP", zap.Error(err))
} }
} else { } else {
@@ -467,8 +461,7 @@ func handleMsgMhfGetGemInfo(s *Session, p mhfpacket.MHFPacket) {
gemInfo := []GemInfo{} gemInfo := []GemInfo{}
gemHistory := []GemHistory{} gemHistory := []GemHistory{}
var tempGems string tempGems, _ := s.server.towerRepo.GetGems(s.charID)
_ = s.server.db.QueryRow(`SELECT COALESCE(gems, $1) FROM tower WHERE char_id=$2`, EmptyTowerCSV(30), s.charID).Scan(&tempGems)
for i, v := range stringsupport.CSVElems(tempGems) { for i, v := range stringsupport.CSVElems(tempGems) {
if v < 0 || v > math.MaxUint16 { if v < 0 || v > math.MaxUint16 {
continue continue
@@ -513,12 +506,10 @@ func handleMsgMhfPostGemInfo(s *Session, p mhfpacket.MHFPacket) {
) )
} }
var gems string
_ = s.server.db.QueryRow(`SELECT COALESCE(gems, $1) FROM tower WHERE char_id=$2`, EmptyTowerCSV(30), s.charID).Scan(&gems)
switch pkt.Op { switch pkt.Op {
case 1: // Add gem case 1: // Add gem
i := int((pkt.Gem >> 8 * 5) + (pkt.Gem - pkt.Gem&0xFF00 - 1%5)) i := int((pkt.Gem >> 8 * 5) + (pkt.Gem - pkt.Gem&0xFF00 - 1%5))
if _, err := s.server.db.Exec(`UPDATE tower SET gems=$1 WHERE char_id=$2`, stringsupport.CSVSetIndex(gems, i, stringsupport.CSVGetIndex(gems, i)+int(pkt.Quantity)), s.charID); err != nil { if err := s.server.towerRepo.AddGem(s.charID, i, int(pkt.Quantity)); err != nil {
s.logger.Error("Failed to update tower gems", zap.Error(err)) s.logger.Error("Failed to update tower gems", zap.Error(err))
} }
case 2: // Transfer gem case 2: // Transfer gem

View File

@@ -0,0 +1,228 @@
package channelserver
import (
"database/sql"
"github.com/jmoiron/sqlx"
)
// FestaRepository centralizes all database access for festa-related tables
// (events, festa_registrations, festa_submissions, festa_prizes, festa_prizes_accepted, festa_trials, guild_characters).
type FestaRepository struct {
db *sqlx.DB
}
// NewFestaRepository creates a new FestaRepository.
func NewFestaRepository(db *sqlx.DB) *FestaRepository {
return &FestaRepository{db: db}
}
// FestaEvent represents a festa event row.
type FestaEvent struct {
ID uint32 `db:"id"`
StartTime uint32 `db:"start_time"`
}
// FestaGuildRanking holds a guild's ranking result for a trial or daily window.
type FestaGuildRanking struct {
GuildID uint32
GuildName string
Team FestivalColor
Souls uint32
}
// CleanupAll removes all festa state: events, registrations, submissions, accepted prizes, and trial votes.
func (r *FestaRepository) CleanupAll() error {
for _, q := range []string{
"DELETE FROM events WHERE event_type='festa'",
"DELETE FROM festa_registrations",
"DELETE FROM festa_submissions",
"DELETE FROM festa_prizes_accepted",
"UPDATE guild_characters SET trial_vote=NULL",
} {
if _, err := r.db.Exec(q); err != nil {
return err
}
}
return nil
}
// InsertEvent creates a new festa event with the given start time.
func (r *FestaRepository) InsertEvent(startTime uint32) error {
_, err := r.db.Exec(
"INSERT INTO events (event_type, start_time) VALUES ('festa', to_timestamp($1)::timestamp without time zone)",
startTime,
)
return err
}
// GetFestaEvents returns all festa events (id and start_time as epoch).
func (r *FestaRepository) GetFestaEvents() ([]FestaEvent, error) {
var events []FestaEvent
rows, err := r.db.Queryx("SELECT id, (EXTRACT(epoch FROM start_time)::int) as start_time FROM events WHERE event_type='festa'")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var e FestaEvent
if err := rows.StructScan(&e); err != nil {
continue
}
events = append(events, e)
}
return events, nil
}
// GetTeamSouls returns the total souls for a given team color ("blue" or "red").
func (r *FestaRepository) GetTeamSouls(team string) (uint32, error) {
var souls uint32
err := r.db.QueryRow(
`SELECT COALESCE(SUM(fs.souls), 0) AS souls FROM festa_registrations fr LEFT JOIN festa_submissions fs ON fr.guild_id = fs.guild_id AND fr.team = $1`,
team,
).Scan(&souls)
return souls, err
}
// GetTrialsWithMonopoly returns all festa trials with their computed monopoly color.
func (r *FestaRepository) GetTrialsWithMonopoly() ([]FestaTrial, error) {
var trials []FestaTrial
rows, err := r.db.Queryx(`SELECT ft.*,
COALESCE(CASE
WHEN COUNT(gc.id) FILTER (WHERE fr.team = 'blue' AND gc.trial_vote = ft.id) >
COUNT(gc.id) FILTER (WHERE fr.team = 'red' AND gc.trial_vote = ft.id)
THEN CAST('blue' AS public.festival_color)
WHEN COUNT(gc.id) FILTER (WHERE fr.team = 'red' AND gc.trial_vote = ft.id) >
COUNT(gc.id) FILTER (WHERE fr.team = 'blue' AND gc.trial_vote = ft.id)
THEN CAST('red' AS public.festival_color)
END, CAST('none' AS public.festival_color)) AS monopoly
FROM public.festa_trials ft
LEFT JOIN public.guild_characters gc ON ft.id = gc.trial_vote
LEFT JOIN public.festa_registrations fr ON gc.guild_id = fr.guild_id
GROUP BY ft.id`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var trial FestaTrial
if err := rows.StructScan(&trial); err != nil {
continue
}
trials = append(trials, trial)
}
return trials, nil
}
// GetTopGuildForTrial returns the top-scoring guild for a given trial type.
// Returns sql.ErrNoRows if no submissions exist.
func (r *FestaRepository) GetTopGuildForTrial(trialType uint16) (FestaGuildRanking, error) {
var ranking FestaGuildRanking
var temp uint32
ranking.Team = FestivalColorNone
err := r.db.QueryRow(`
SELECT fs.guild_id, g.name, fr.team, SUM(fs.souls) as _
FROM festa_submissions fs
LEFT JOIN festa_registrations fr ON fs.guild_id = fr.guild_id
LEFT JOIN guilds g ON fs.guild_id = g.id
WHERE fs.trial_type = $1
GROUP BY fs.guild_id, g.name, fr.team
ORDER BY _ DESC LIMIT 1
`, trialType).Scan(&ranking.GuildID, &ranking.GuildName, &ranking.Team, &temp)
return ranking, err
}
// GetTopGuildInWindow returns the top-scoring guild within a time window (epoch seconds).
// Returns sql.ErrNoRows if no submissions exist.
func (r *FestaRepository) GetTopGuildInWindow(start, end uint32) (FestaGuildRanking, error) {
var ranking FestaGuildRanking
var temp uint32
ranking.Team = FestivalColorNone
err := r.db.QueryRow(`
SELECT fs.guild_id, g.name, fr.team, SUM(fs.souls) as _
FROM festa_submissions fs
LEFT JOIN festa_registrations fr ON fs.guild_id = fr.guild_id
LEFT JOIN guilds g ON fs.guild_id = g.id
WHERE EXTRACT(EPOCH FROM fs.timestamp)::int > $1 AND EXTRACT(EPOCH FROM fs.timestamp)::int < $2
GROUP BY fs.guild_id, g.name, fr.team
ORDER BY _ DESC LIMIT 1
`, start, end).Scan(&ranking.GuildID, &ranking.GuildName, &ranking.Team, &temp)
return ranking, err
}
// GetCharSouls returns the total souls submitted by a character.
func (r *FestaRepository) GetCharSouls(charID uint32) (uint32, error) {
var souls uint32
err := r.db.QueryRow(
`SELECT COALESCE((SELECT SUM(souls) FROM festa_submissions WHERE character_id=$1), 0)`,
charID,
).Scan(&souls)
return souls, err
}
// HasClaimedMainPrize checks if a character has claimed the main festa prize (prize_id=0).
func (r *FestaRepository) HasClaimedMainPrize(charID uint32) bool {
var exists uint32
err := r.db.QueryRow("SELECT prize_id FROM festa_prizes_accepted WHERE prize_id=0 AND character_id=$1", charID).Scan(&exists)
return err == nil
}
// VoteTrial sets a character's trial vote.
func (r *FestaRepository) VoteTrial(charID uint32, trialID uint32) error {
_, err := r.db.Exec(`UPDATE guild_characters SET trial_vote=$1 WHERE character_id=$2`, trialID, charID)
return err
}
// RegisterGuild registers a guild for a festa team.
func (r *FestaRepository) RegisterGuild(guildID uint32, team string) error {
_, err := r.db.Exec("INSERT INTO festa_registrations VALUES ($1, $2)", guildID, team)
return err
}
// SubmitSouls records soul submissions for a character within a transaction.
func (r *FestaRepository) SubmitSouls(charID, guildID uint32, souls []uint16) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
for i, s := range souls {
if s == 0 {
continue
}
if _, err := tx.Exec(`INSERT INTO festa_submissions VALUES ($1, $2, $3, $4, now())`, charID, guildID, i, s); err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
// ClaimPrize records that a character has claimed a festa prize.
func (r *FestaRepository) ClaimPrize(prizeID uint32, charID uint32) error {
_, err := r.db.Exec("INSERT INTO public.festa_prizes_accepted VALUES ($1, $2)", prizeID, charID)
return err
}
// ListPrizes returns festa prizes of the given type with a claimed flag for the character.
func (r *FestaRepository) ListPrizes(charID uint32, prizeType string) ([]Prize, error) {
var prizes []Prize
rows, err := r.db.Queryx(
`SELECT id, tier, souls_req, item_id, num_item, (SELECT count(*) FROM festa_prizes_accepted fpa WHERE fp.id = fpa.prize_id AND fpa.character_id = $1) AS claimed FROM festa_prizes fp WHERE type=$2`,
charID, prizeType,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var prize Prize
if err := rows.StructScan(&prize); err != nil {
continue
}
prizes = append(prizes, prize)
}
return prizes, nil
}
// ensure sql import is used
var _ = sql.ErrNoRows

View File

@@ -0,0 +1,164 @@
package channelserver
import (
"fmt"
"erupe-ce/common/stringsupport"
"github.com/jmoiron/sqlx"
)
// TowerRepository centralizes all database access for tower-related tables
// (tower, guilds tower columns, guild_characters tower columns).
type TowerRepository struct {
db *sqlx.DB
}
// NewTowerRepository creates a new TowerRepository.
func NewTowerRepository(db *sqlx.DB) *TowerRepository {
return &TowerRepository{db: db}
}
// TowerData holds the core tower stats for a character.
type TowerData struct {
TR int32
TRP int32
TSP int32
Block1 int32
Block2 int32
Skills string
}
// GetTowerData returns tower stats for a character, creating the row if it doesn't exist.
func (r *TowerRepository) GetTowerData(charID uint32) (TowerData, error) {
var td TowerData
err := r.db.QueryRow(
`SELECT COALESCE(tr, 0), COALESCE(trp, 0), COALESCE(tsp, 0), COALESCE(block1, 0), COALESCE(block2, 0), COALESCE(skills, $1) FROM tower WHERE char_id=$2`,
EmptyTowerCSV(64), charID,
).Scan(&td.TR, &td.TRP, &td.TSP, &td.Block1, &td.Block2, &td.Skills)
if err != nil {
_, err = r.db.Exec(`INSERT INTO tower (char_id) VALUES ($1)`, charID)
return TowerData{Skills: EmptyTowerCSV(64)}, err
}
return td, nil
}
// GetSkills returns the skills CSV string for a character.
func (r *TowerRepository) GetSkills(charID uint32) (string, error) {
var skills string
err := r.db.QueryRow(`SELECT COALESCE(skills, $1) FROM tower WHERE char_id=$2`, EmptyTowerCSV(64), charID).Scan(&skills)
return skills, err
}
// UpdateSkills updates a single skill and deducts TSP cost.
func (r *TowerRepository) UpdateSkills(charID uint32, skills string, cost int32) error {
_, err := r.db.Exec(`UPDATE tower SET skills=$1, tsp=tsp-$2 WHERE char_id=$3`, skills, cost, charID)
return err
}
// UpdateProgress updates tower progress (TR, TRP, TSP, block1).
func (r *TowerRepository) UpdateProgress(charID uint32, tr, trp, cost, block1 int32) error {
_, err := r.db.Exec(
`UPDATE tower SET tr=$1, trp=COALESCE(trp, 0)+$2, tsp=COALESCE(tsp, 0)+$3, block1=COALESCE(block1, 0)+$4 WHERE char_id=$5`,
tr, trp, cost, block1, charID,
)
return err
}
// GetGems returns the gems CSV string for a character.
func (r *TowerRepository) GetGems(charID uint32) (string, error) {
var gems string
err := r.db.QueryRow(`SELECT COALESCE(gems, $1) FROM tower WHERE char_id=$2`, EmptyTowerCSV(30), charID).Scan(&gems)
return gems, err
}
// UpdateGems saves the gems CSV string for a character.
func (r *TowerRepository) UpdateGems(charID uint32, gems string) error {
_, err := r.db.Exec(`UPDATE tower SET gems=$1 WHERE char_id=$2`, gems, charID)
return err
}
// AddGem adds quantity to a specific gem index.
func (r *TowerRepository) AddGem(charID uint32, gemIndex int, quantity int) error {
gems, err := r.GetGems(charID)
if err != nil {
return err
}
newGems := stringsupport.CSVSetIndex(gems, gemIndex, stringsupport.CSVGetIndex(gems, gemIndex)+quantity)
return r.UpdateGems(charID, newGems)
}
// TenrouiraiProgressData holds the guild's tenrouirai (sky corridor) progress.
type TenrouiraiProgressData struct {
Page uint8
Mission1 uint16
Mission2 uint16
Mission3 uint16
}
// GetTenrouiraiProgress returns the guild's tower mission page and aggregated mission scores.
func (r *TowerRepository) GetTenrouiraiProgress(guildID uint32) (TenrouiraiProgressData, error) {
var p TenrouiraiProgressData
if err := r.db.QueryRow(`SELECT tower_mission_page FROM guilds WHERE id=$1`, guildID).Scan(&p.Page); err != nil {
return p, err
}
_ = r.db.QueryRow(
`SELECT SUM(tower_mission_1) AS _, SUM(tower_mission_2) AS _, SUM(tower_mission_3) AS _ FROM guild_characters WHERE guild_id=$1`,
guildID,
).Scan(&p.Mission1, &p.Mission2, &p.Mission3)
return p, nil
}
// GetTenrouiraiMissionScores returns per-character scores for a specific mission index (1-3).
func (r *TowerRepository) GetTenrouiraiMissionScores(guildID uint32, missionIndex uint8) ([]TenrouiraiCharScore, error) {
if missionIndex < 1 || missionIndex > 3 {
missionIndex = (missionIndex % 3) + 1
}
rows, err := r.db.Query(
fmt.Sprintf(
`SELECT name, tower_mission_%d FROM guild_characters gc INNER JOIN characters c ON gc.character_id = c.id WHERE guild_id=$1 AND tower_mission_%d IS NOT NULL ORDER BY tower_mission_%d DESC`,
missionIndex, missionIndex, missionIndex,
),
guildID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var scores []TenrouiraiCharScore
for rows.Next() {
var cs TenrouiraiCharScore
if err := rows.Scan(&cs.Name, &cs.Score); err == nil {
scores = append(scores, cs)
}
}
return scores, nil
}
// GetGuildTowerRP returns the guild's tower RP.
func (r *TowerRepository) GetGuildTowerRP(guildID uint32) (uint32, error) {
var rp uint32
err := r.db.QueryRow(`SELECT tower_rp FROM guilds WHERE id=$1`, guildID).Scan(&rp)
return rp, err
}
// GetGuildTowerPageAndRP returns the guild's tower mission page and donated RP.
func (r *TowerRepository) GetGuildTowerPageAndRP(guildID uint32) (page int, donated int, err error) {
err = r.db.QueryRow(`SELECT tower_mission_page, tower_rp FROM guilds WHERE id=$1`, guildID).Scan(&page, &donated)
return
}
// AdvanceTenrouiraiPage increments the guild's tower mission page and resets member mission progress.
func (r *TowerRepository) AdvanceTenrouiraiPage(guildID uint32) error {
if _, err := r.db.Exec(`UPDATE guilds SET tower_mission_page=tower_mission_page+1 WHERE id=$1`, guildID); err != nil {
return err
}
_, err := r.db.Exec(`UPDATE guild_characters SET tower_mission_1=NULL, tower_mission_2=NULL, tower_mission_3=NULL WHERE guild_id=$1`, guildID)
return err
}
// DonateGuildTowerRP adds RP to the guild's tower total.
func (r *TowerRepository) DonateGuildTowerRP(guildID uint32, rp uint16) error {
_, err := r.db.Exec(`UPDATE guilds SET tower_rp=tower_rp+$1 WHERE id=$2`, rp, guildID)
return err
}

View File

@@ -590,12 +590,22 @@ func createTestServerWithDB(t *testing.T, db *sqlx.DB) *Server {
RealClientMode: _config.ZZ, RealClientMode: _config.ZZ,
}, },
isShuttingDown: false, isShuttingDown: false,
done: make(chan struct{}),
} }
// Create logger // Create logger
logger, _ := zap.NewDevelopment() logger, _ := zap.NewDevelopment()
server.logger = logger server.logger = logger
// Initialize repositories
server.charRepo = NewCharacterRepository(db)
server.guildRepo = NewGuildRepository(db)
server.userRepo = NewUserRepository(db)
server.gachaRepo = NewGachaRepository(db)
server.houseRepo = NewHouseRepository(db)
server.festaRepo = NewFestaRepository(db)
server.towerRepo = NewTowerRepository(db)
return server return server
} }

View File

@@ -50,6 +50,8 @@ type Server struct {
userRepo *UserRepository userRepo *UserRepository
gachaRepo *GachaRepository gachaRepo *GachaRepository
houseRepo *HouseRepository houseRepo *HouseRepository
festaRepo *FestaRepository
towerRepo *TowerRepository
erupeConfig *_config.Config erupeConfig *_config.Config
acceptConns chan net.Conn acceptConns chan net.Conn
deleteConns chan net.Conn deleteConns chan net.Conn
@@ -125,6 +127,8 @@ func NewServer(config *Config) *Server {
s.userRepo = NewUserRepository(config.DB) s.userRepo = NewUserRepository(config.DB)
s.gachaRepo = NewGachaRepository(config.DB) s.gachaRepo = NewGachaRepository(config.DB)
s.houseRepo = NewHouseRepository(config.DB) s.houseRepo = NewHouseRepository(config.DB)
s.festaRepo = NewFestaRepository(config.DB)
s.towerRepo = NewTowerRepository(config.DB)
// Mezeporta // Mezeporta
s.stages["sl1Ns200p0a0u0"] = NewStage("sl1Ns200p0a0u0") s.stages["sl1Ns200p0a0u0"] = NewStage("sl1Ns200p0a0u0")

View File

@@ -329,3 +329,16 @@ func CreateTestGuild(t *testing.T, db *sqlx.DB, leaderCharID uint32, name string
return guildID return guildID
} }
// SetTestDB assigns a database to a Server and initializes all repositories.
// Use this in integration tests instead of setting s.server.db directly.
func SetTestDB(s *Server, db *sqlx.DB) {
s.db = db
s.charRepo = NewCharacterRepository(db)
s.guildRepo = NewGuildRepository(db)
s.userRepo = NewUserRepository(db)
s.gachaRepo = NewGachaRepository(db)
s.houseRepo = NewHouseRepository(db)
s.festaRepo = NewFestaRepository(db)
s.towerRepo = NewTowerRepository(db)
}