forfeits added
This commit is contained in:
@@ -31,6 +31,12 @@ type FixtureResult struct {
|
||||
TamperingDetected bool `bun:",default:false"`
|
||||
TamperingReason *string
|
||||
|
||||
// Forfeit-related fields
|
||||
IsForfeit bool `bun:"is_forfeit,default:false"`
|
||||
ForfeitType *string `bun:"forfeit_type"` // "mutual" or "outright"
|
||||
ForfeitTeam *string `bun:"forfeit_team"` // "home" or "away" (nil for mutual)
|
||||
ForfeitReason *string `bun:"forfeit_reason"` // User-provided reason
|
||||
|
||||
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
|
||||
UploadedBy *User `bun:"rel:belongs-to,join:uploaded_by_user_id=id"`
|
||||
PlayerStats []*FixtureResultPlayerStats `bun:"rel:has-many,join:id=fixture_result_id"`
|
||||
@@ -95,6 +101,106 @@ type PlayerWithPlayStatus struct {
|
||||
Stats *FixtureResultPlayerStats // Period 3 (final cumulative) stats, nil if no result
|
||||
}
|
||||
|
||||
// Forfeit type constants
|
||||
const (
|
||||
ForfeitTypeMutual = "mutual"
|
||||
ForfeitTypeOutright = "outright"
|
||||
)
|
||||
|
||||
// CreateForfeitResult creates a finalized forfeit result for a fixture.
|
||||
// For outright forfeits, forfeitTeam specifies which team ("home" or "away") forfeited.
|
||||
// For mutual forfeits, forfeitTeam should be empty.
|
||||
func CreateForfeitResult(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixture *Fixture,
|
||||
forfeitType string,
|
||||
forfeitTeam string,
|
||||
reason string,
|
||||
userID int,
|
||||
audit *AuditMeta,
|
||||
) (*FixtureResult, error) {
|
||||
if fixture == nil {
|
||||
return nil, errors.New("fixture cannot be nil")
|
||||
}
|
||||
|
||||
// Validate forfeit type
|
||||
if forfeitType != ForfeitTypeMutual && forfeitType != ForfeitTypeOutright {
|
||||
return nil, BadRequest("invalid forfeit type: must be 'mutual' or 'outright'")
|
||||
}
|
||||
|
||||
// Validate forfeit team for outright forfeits
|
||||
if forfeitType == ForfeitTypeOutright {
|
||||
if forfeitTeam != "home" && forfeitTeam != "away" {
|
||||
return nil, BadRequest("outright forfeit requires a team: must be 'home' or 'away'")
|
||||
}
|
||||
}
|
||||
|
||||
// Determine winner and scores based on forfeit type
|
||||
var winner string
|
||||
var homeScore, awayScore int
|
||||
var endReason string
|
||||
var forfeitTeamPtr *string
|
||||
|
||||
switch forfeitType {
|
||||
case ForfeitTypeMutual:
|
||||
// Mutual forfeit: both teams get an OT loss, no winner
|
||||
// Use "draw" as winner to signal mutual loss
|
||||
winner = "draw"
|
||||
homeScore = 0
|
||||
awayScore = 0
|
||||
endReason = "Forfeit"
|
||||
case ForfeitTypeOutright:
|
||||
// Outright forfeit: forfeiting team loses, opponent wins
|
||||
forfeitTeamPtr = &forfeitTeam
|
||||
if forfeitTeam == "home" {
|
||||
winner = "away"
|
||||
} else {
|
||||
winner = "home"
|
||||
}
|
||||
homeScore = 0
|
||||
awayScore = 0
|
||||
endReason = "Forfeit"
|
||||
}
|
||||
|
||||
var reasonPtr *string
|
||||
if reason != "" {
|
||||
reasonPtr = &reason
|
||||
}
|
||||
|
||||
result := &FixtureResult{
|
||||
FixtureID: fixture.ID,
|
||||
Winner: winner,
|
||||
HomeScore: homeScore,
|
||||
AwayScore: awayScore,
|
||||
EndReason: endReason,
|
||||
UploadedByUserID: userID,
|
||||
Finalized: true, // Forfeits are immediately finalized
|
||||
IsForfeit: true,
|
||||
ForfeitType: &forfeitType,
|
||||
ForfeitTeam: forfeitTeamPtr,
|
||||
ForfeitReason: reasonPtr,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
err := Insert(tx, result).WithAudit(audit, &AuditInfo{
|
||||
Action: "fixture_results.forfeit",
|
||||
ResourceType: "fixture_result",
|
||||
ResourceID: nil,
|
||||
Details: map[string]any{
|
||||
"fixture_id": fixture.ID,
|
||||
"forfeit_type": forfeitType,
|
||||
"forfeit_team": forfeitTeam,
|
||||
"reason": reason,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Insert forfeit result")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// InsertFixtureResult stores a new match result with all player stats in a single transaction.
|
||||
func InsertFixtureResult(
|
||||
ctx context.Context,
|
||||
@@ -352,6 +458,7 @@ const (
|
||||
|
||||
// ComputeTeamRecord calculates W-OTW-OTL-L, GF/GA, and points from fixtures and results.
|
||||
// Points: Win=3, OT Win=2, OT Loss=1, Loss=0.
|
||||
// Forfeits: Outright = Win(3)/Loss(0), Mutual = OT Loss(1) for both teams.
|
||||
func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*FixtureResult) *TeamRecord {
|
||||
rec := &TeamRecord{}
|
||||
for _, f := range fixtures {
|
||||
@@ -361,6 +468,34 @@ func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*Fixtu
|
||||
}
|
||||
rec.Played++
|
||||
isHome := f.HomeTeamID == teamID
|
||||
|
||||
// Handle forfeits separately
|
||||
if res.IsForfeit {
|
||||
// Forfeits have 0-0 score, no goal impact
|
||||
if res.ForfeitType != nil && *res.ForfeitType == ForfeitTypeMutual {
|
||||
// Mutual forfeit: both teams get OT loss (1 point)
|
||||
rec.OvertimeLosses++
|
||||
rec.Points += PointsOvertimeLoss
|
||||
} else if res.ForfeitType != nil && *res.ForfeitType == ForfeitTypeOutright {
|
||||
// Outright forfeit: check if this team forfeited
|
||||
thisSide := "away"
|
||||
if isHome {
|
||||
thisSide = "home"
|
||||
}
|
||||
if res.ForfeitTeam != nil && *res.ForfeitTeam == thisSide {
|
||||
// This team forfeited - loss
|
||||
rec.Losses++
|
||||
rec.Points += PointsLoss
|
||||
} else {
|
||||
// Opponent forfeited - win
|
||||
rec.Wins++
|
||||
rec.Points += PointsWin
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Normal match handling
|
||||
if isHome {
|
||||
rec.GoalsFor += res.HomeScore
|
||||
rec.GoalsAgainst += res.AwayScore
|
||||
|
||||
89
internal/db/migrations/20260305140000_add_forfeit_support.go
Normal file
89
internal/db/migrations/20260305140000_add_forfeit_support.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Migrations.MustRegister(
|
||||
// UP migration
|
||||
func(ctx context.Context, conn *bun.DB) error {
|
||||
// Add is_forfeit column
|
||||
_, err := conn.NewAddColumn().
|
||||
Model((*db.FixtureResult)(nil)).
|
||||
ColumnExpr("is_forfeit BOOLEAN NOT NULL DEFAULT false").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add forfeit_type column
|
||||
_, err = conn.NewAddColumn().
|
||||
Model((*db.FixtureResult)(nil)).
|
||||
ColumnExpr("forfeit_type VARCHAR").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add forfeit_team column
|
||||
_, err = conn.NewAddColumn().
|
||||
Model((*db.FixtureResult)(nil)).
|
||||
ColumnExpr("forfeit_team VARCHAR").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add forfeit_reason column
|
||||
_, err = conn.NewAddColumn().
|
||||
Model((*db.FixtureResult)(nil)).
|
||||
ColumnExpr("forfeit_reason VARCHAR").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
// DOWN migration
|
||||
func(ctx context.Context, conn *bun.DB) error {
|
||||
_, err := conn.NewDropColumn().
|
||||
Model((*db.FixtureResult)(nil)).
|
||||
ColumnExpr("forfeit_reason").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = conn.NewDropColumn().
|
||||
Model((*db.FixtureResult)(nil)).
|
||||
ColumnExpr("forfeit_team").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = conn.NewDropColumn().
|
||||
Model((*db.FixtureResult)(nil)).
|
||||
ColumnExpr("forfeit_type").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = conn.NewDropColumn().
|
||||
Model((*db.FixtureResult)(nil)).
|
||||
ColumnExpr("is_forfeit").
|
||||
Exec(ctx)
|
||||
return err
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user