mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +01:00
Add zero-dependency SQLite mode so users can run Erupe without
PostgreSQL. A transparent db.DB wrapper auto-translates PostgreSQL
SQL ($N placeholders, now(), ::casts, ILIKE, public. prefix,
TRUNCATE) for SQLite at runtime — all 28 repo files use the wrapper
with no per-query changes needed.
Setup wizard gains two new steps: quest file detection with download
link, and gameplay presets (solo/small/community/rebalanced). The API
server gets a /dashboard endpoint with auto-refreshing stats.
CI release workflow now builds and pushes Docker images to GHCR
alongside binary artifacts on tag push.
Key changes:
- common/db: DB/Tx wrapper with 6 SQL translation rules
- server/migrations/sqlite: full SQLite schema (0001-0005)
- config: Database.Driver field ("postgres" or "sqlite")
- main.go: SQLite connection with WAL mode, single writer
- server/setup: quest check + preset selection steps
- server/api: /dashboard with live stats
- .github/workflows: Docker in release, deduplicate docker.yml
229 lines
7.6 KiB
Go
229 lines
7.6 KiB
Go
package channelserver
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
|
|
dbutil "erupe-ce/common/db"
|
|
)
|
|
|
|
// 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 *dbutil.DB
|
|
}
|
|
|
|
// NewFestaRepository creates a new FestaRepository.
|
|
func NewFestaRepository(db *dbutil.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
|