package channelserver import ( "context" "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 func() { _ = 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 func() { _ = 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. // All entries are inserted; callers should pre-filter zero values. func (r *FestaRepository) SubmitSouls(charID, guildID uint32, souls []uint16) error { tx, err := r.db.BeginTxx(context.Background(), nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() for i, s := range souls { if _, err := tx.Exec(`INSERT INTO festa_submissions VALUES ($1, $2, $3, $4, now())`, charID, guildID, i, s); err != nil { 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 func() { _ = 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