forfeits added

This commit is contained in:
2026-03-05 22:22:32 +11:00
parent 36b42d6267
commit 08344877c7
8 changed files with 746 additions and 83 deletions

View File

@@ -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

View 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
},
)
}