Documentation
¶
Overview ¶
Package engine, daihyosen (representative-bout) tie-breaker for tied knockout-stage team matches.
Daihyosen is the FIK tie-breaker for knockout team matches that end with both teams equal on Individual Victories (IV) AND Points Won (PW). Each team sends ONE representative competitor who plays a single one-ippon bout to decide the winner, there are no further fall-backs once daihyosen is reached.
FR-046, data-model §1, CHK026.
Package engine, engi.go owns the ENTIRE Engi-kyogi (kata competition / flag scoring) vertical slice. Engi is a second scoring paradigm: bouts are decided by referee flag counts (FlagsA/FlagsB) instead of ippon waza letters, and standings rank by wins then accumulated own-side flags.
HARD SEPARATION PRINCIPLE (user directive): engi logic MUST NOT be mixed into the kendo scoring code. There are no `if comp.Engi` branches sprinkled through computeStandingsFrom, writeMatchResult, recordBracketMatchResult, or the shared tie-break logic. The kendo functions are BRANCHED AROUND at single dispatch seams (RecordMatchResultWithIneligibility(+Tx) and computeStandings) that delegate here; they are never edited internally. The only shared seam is the additive persistence DTO fields (MatchResult.FlagsA/FlagsB, PlayerStanding.Flags, Competition.Engi).
Reusing the PURE helper propagateBracketWinner is allowed: it only advances a decided winner's name forward and computes no score, so it is not kendo scoring logic.
Package engine, kachinuki "winner-stays-on" team match advancement.
FR-044, data-model §4.1.
Kachinuki is a team-match format where:
- Only the first bout is scheduled up front.
- After each bout the winner stays on the court and faces the next un-retired player from the losing team.
- On a hikiwake (draw) BOTH players retire and the next pair from each remaining roster advance.
- The team match ends when one side has no remaining un-retired players, the other side wins by exhaustion (domain.DecisionKachinukiExhaustion).
AdvanceKachinuki encapsulates the pure decision logic. Callers (typically a score handler, see handlers_match.go) pass a snapshot of the just-completed bout plus the remaining un-retired roster per side, and the engine returns either the next bout to schedule or a MatchEnded sentinel.
Package engine, scoring_tx.go owns the tx-aware twins of RecordMatchResult / RecordMatchResultWithIneligibility / recordBracketMatchResult / recordIneligibilityFromDecision. They accept a state.StoreTx instead of reaching at e.store directly, so a caller (typically a HTTP handler) can run them inside a single Store.WithTransaction acquire of the per-comp write lock.
Why these exist. Pre-T156 the score and decision handlers called engine methods that each acquired their own per-comp lock via UpdatePoolMatchByID / UpdateBracket / SetCompetitorStatus. The handler's logical "score this match" operation translated to 3-5 separate lock acquires, with concurrent writers free to land mutations in the gaps. The tx-aware twins collapse all of those into ONE acquire so the entire match-write + ineligibility-write sequence is indivisible.
Constraint. Methods here MUST call only the tx parameter, NEVER e.store directly. The per-comp lock is non-reentrant (sync.RWMutex is not recursive on Lock by Lock); a direct e.store.Save* call from inside the closure passed to WithTransaction would deadlock.
T156, NFR-010.
Index ¶
- Constants
- Variables
- func ComputeTeamSummary(subResults []state.SubMatchResult, sideAName, sideBName string) (TeamSummary, TeamSummary)
- func FilterRemaining(roster []string, retired map[string]struct{}) []string
- func IsPoolDaihyosenMatchID(matchID string) bool
- func IsPoolMatchID(matchID string) bool
- func IsTiebreakerMatchID(matchID string) bool
- func IsTied(a, b TeamSummary) bool
- func RetiredPlayersFromBoutLog(boutLog []state.SubMatchResult, teamAName, teamBName string) (retiredA, retiredB map[string]struct{})
- func SuggestedMaxCourts(numPlayers int) int
- func ValidateCourtCount(numPlayers, numCourts int) error
- type AdvanceKachinukiInput
- type AdvanceKachinukiResult
- type AlreadyIneligibleError
- type AutoCompleteOutcome
- type ChusenGroup
- type ClashWarning
- type CourtBusyError
- type DownstreamKnockoutScoredError
- type Engine
- func (e *Engine) AddDaihyosen(compID, matchID string, sideA, sideB TeamSummary, isPool bool, ...) (*state.SubMatchResult, error)
- func (e *Engine) AdvanceSwissRound(compID string) ([]state.MatchResult, int, error)
- func (e *Engine) CalculatePoolStandings(compId string) (map[string][]state.PlayerStanding, error)
- func (e *Engine) CheckCrossCompCourtBusy(compID, matchID string) error
- func (e *Engine) CheckEligibility(compID string, playerIDs []string) error
- func (e *Engine) CheckKachinukiPrematureCompletion(compID, matchID string, result *state.MatchResult) error
- func (e *Engine) ChusenCandidates(compID string) ([]ChusenGroup, error)
- func (e *Engine) CurrentSwissRoundCompleted(compID string) (bool, error)
- func (e *Engine) DetectClashesForCompetition(compID string) ([]ClashWarning, error)
- func (e *Engine) DiscardDraw(id string) error
- func (e *Engine) EstimateScheduleForCompetition(compID string) (ScheduleEstimate, error)
- func (e *Engine) ExportCompetitionXlsx(id string) ([]byte, error)
- func (e *Engine) ExportTournamentWorkbooks(tmpDir string, compIDs ...string) ([]pdf.SourceWorkbook, error)
- func (e *Engine) GenerateDraw(id string) error
- func (e *Engine) GenerateLeagueTiebreakMatches(compID string, tiedTeamNames []string) ([]state.MatchResult, error)
- func (e *Engine) GenerateSchedule(compID string) error
- func (e *Engine) GenerateSwissRound(compID string, roundNumber int) ([]state.MatchResult, error)
- func (e *Engine) GetBracketRanking(compID string, rank int) (*domain.Player, error)
- func (e *Engine) GetPoolRanking(compID string, rank int) (*domain.Player, error)
- func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult, error)
- func (e *Engine) InjectTiebreakerMatches(compID string) ([]state.MatchResult, error)
- func (e *Engine) KachinukiDetailMatches(id string) ([]helper.KachinukiMatchDetail, error)
- func (e *Engine) LeagueStandings(compID string) ([]state.PlayerStanding, error)
- func (e *Engine) LeagueTiebreakCandidates(compID string) ([]TiedGroup, error)
- func (e *Engine) MaybeAdvanceKachinuki(compID, matchID string) (bool, error)
- func (e *Engine) MaybeAutoCompletePools(compID string) (AutoCompleteOutcome, error)
- func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName string, modifiedAt int64) (bool, error)
- func (e *Engine) RecordDecision(compID, matchID, decision, decisionBy, decisionReason string, ...) (*state.MatchResult, *domain.CompetitorStatus, error)
- func (e *Engine) RecordDecisionTx(tx state.StoreTx, compID, matchID, decision, decisionBy, decisionReason string, ...) (*state.MatchResult, *domain.CompetitorStatus, error)
- func (e *Engine) RecordMatchResult(compId string, matchId string, result *state.MatchResult) error
- func (e *Engine) RecordMatchResultWithIneligibility(compId string, matchId string, result *state.MatchResult) (*domain.CompetitorStatus, error)
- func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, matchID string, result *state.MatchResult) (*domain.CompetitorStatus, error)
- func (e *Engine) ReinstateCompetitor(compID, playerID string) (*domain.CompetitorStatus, error)
- func (e *Engine) ReplaceParticipantInDraw(compID string, oldName, oldDojo, oldDisplayName string, ...) (warnings []string, err error)
- func (e *Engine) ResolveQualifiedPools(compID string) (int, bool, error)
- func (e *Engine) RevertMatchToQueue(compId, matchId string) error
- func (e *Engine) StartCompetition(id string) error
- func (e *Engine) StartMatch(compID, matchID string) error
- func (e *Engine) StartMatchTx(tx state.StoreTx, compID, matchID string) error
- func (e *Engine) SwissStandings(compID string) ([]state.PlayerStanding, error)
- func (e *Engine) UpdateMatchCourt(compId string, matchId string, newCourt string) error
- func (e *Engine) UpdateMatchTime(compId string, matchId string, scheduledAt string) error
- type EstimateInput
- type IneligibleCompetitorError
- type NotFoundError
- type ScheduleEstimate
- type SwissRoundNotCompletedError
- type TeamSummary
- type TiedGroup
- type ValidationError
Constants ¶
const MaxCourts = 26
MaxCourts is the hard upper bound on the `courts` parameter accepted by EstimateSchedule. Mirrors the CLI's A–Z (26) cap (CLAUDE.md) and is also enforced by the handler so a hostile query-string cannot trigger an excessive allocation (CodeQL go/uncontrolled-allocation-size).
const MinClashFootprintMinutes = 30
MinClashFootprintMinutes is the minimum time footprint assigned to a competition for court-clash detection. A competition's real duration comes from its schedule estimate, which is ~0 before it has a roster; flooring at this value means even empty competitions occupy a slot, so overlaps are caught while the operator is still laying out the schedule. Mirrors the create-form auto-stack minimum block (MIN_STACK_BLOCK_MIN in admin_setup.jsx).
Variables ¶
var ErrCourtBusy = errors.New("court already has a running match")
ErrCourtBusy is the sentinel error matched by errors.Is(err, engine.ErrCourtBusy). The concrete value is a *CourtBusyError that carries Court, MatchID, and CompID.
var ErrDecisionLocked = errors.New("decision locked: a subsequent match has started")
ErrDecisionLocked is returned when a decision-overwrite (kiken-undo or similar) is attempted on a match whose participants have started a subsequent match. Handlers should return HTTP 409.
T103, CHK024.
var ErrDownstreamKnockoutScored = errors.New("downstream knockout match already scored")
ErrDownstreamKnockoutScored is the sentinel matched by errors.Is for DownstreamKnockoutScoredError. Handlers should return HTTP 409.
mp-e2k1.
var ErrIneligibleCompetitor = errors.New("ineligible competitor")
ErrIneligibleCompetitor is the sentinel error matched by errors.Is(err, engine.ErrIneligibleCompetitor). Callers use this for HTTP 409 mapping; the returned concrete value is an *IneligibleCompetitorError that carries PlayerID/Reason for the response body.
FR-035, contracts/match-decisions.md §409.
var ErrInsufficientEligibility = errors.New("daihyosen requires at least one eligible competitor per side")
ErrInsufficientEligibility is returned by AddDaihyosen when one or both teams have zero eligible competitors remaining (every roster member has kiken'd or otherwise been marked ineligible). Callers map this to HTTP 409 ("insufficient_eligibility") and MUST then record the match result with the opposing team as Winner, per CHK026, a team unable to field a representative forfeits the encounter.
var ErrKachinukiPrematureCompletion = errors.New("kachinuki match cannot be completed while both teams still have players remaining")
ErrKachinukiPrematureCompletion is returned by CheckKachinukiPrematureCompletion when a completed-status write would finalize a kachinuki match that still has players remaining on both teams and carries no daihyosen resolution. The score handler maps it to HTTP 409.
var ErrMatchAlreadyCompleted = errors.New("match already completed: use the score editor to correct a completed bout")
ErrMatchAlreadyCompleted is returned by RevertMatchToQueue when the target match has already been completed. A completed bout has a recorded result that the operator must correct via the score editor, not discarded by a revert. Handlers map this to HTTP 409.
var ErrMatchSideMismatch = errors.New("match side mismatch: score payload competitors differ from the stored pairing")
ErrMatchSideMismatch is returned when a score payload names competitors (sideA/sideB) that differ from the match's stored pairing. Match identity is fixed at generation; a score only carries the *result*, never a new pairing. Rejecting prevents a malformed or buggy client from silently rewriting who is in a match, the live path that produced cross-pool pool rows (a stored Pool E bout overwritten with competitors from other pools). Handlers map this to HTTP 409.
var ErrNotTied = errors.New("daihyosen requires a tied IV+PW result")
ErrNotTied is returned by AddDaihyosen when the two TeamSummary values differ on either IV or PW. Callers map this to HTTP 400 ("not_tied"), the operator must score the match normally instead.
var ErrPoolMatch = errors.New("daihyosen is only valid in knockout matches")
ErrPoolMatch is returned by AddDaihyosen when the target match is a pool-stage match. Pool matches are allowed to end in hikiwake (drawn); daihyosen is reserved for knockout-stage matches that must produce a single winner. Callers map this to HTTP 400 ("pool_match").
Functions ¶
func ComputeTeamSummary ¶
func ComputeTeamSummary(subResults []state.SubMatchResult, sideAName, sideBName string) (TeamSummary, TeamSummary)
ComputeTeamSummary aggregates SubMatchResult entries into TeamSummary values for the two named sides. Sub-results whose Winner is empty (drawn bouts / hikiwake) contribute nothing to IV but their ippons still count toward PW for whichever side scored them.
SideA/SideB names are matched against sub.Winner using the same fallback as computeStandings (sub.Winner may carry either the match-level side name or the sub-result-level side name).
Pass the names from the parent MatchResult.SideA / SideB so the caller's view of "left team" / "right team" is canonical.
func FilterRemaining ¶
FilterRemaining returns roster entries that are NOT present in the retired set, preserving original order. Helper for callers building AdvanceKachinukiInput.{SideA,SideB} from a roster and a retired set produced by RetiredPlayersFromBoutLog.
func IsPoolDaihyosenMatchID ¶
IsPoolDaihyosenMatchID reports whether a match ID is a pool-stage daihyosen bout (IDs of the form "Pool X-DH-N"). These are generated by InjectPoolDaihyosenMatches when all team-pool matches complete with a tie on all 8 ranking criteria. They are structurally pool matches but scored as individual (one representative per side) rather than as full team bouts.
func IsPoolMatchID ¶
IsPoolMatchID reports whether a match ID belongs to the pool stage. Pool match IDs follow the "<PoolName>-<idx>" convention generated by helper.CreatePoolMatches (e.g. "Pool A-0", "Pool B-3"). Bracket match IDs use a different shape ("r1-m0", numeric, or other). Centralising this distinction here lets handlers call AddDaihyosen without duplicating the prefix check.
func IsTiebreakerMatchID ¶
IsTiebreakerMatchID reports whether matchID identifies a supplementary ippon-shobu tiebreaker match (IDs of the form "Pool X-TB-N"). Suffix-anchored via hasNumericSuffixAfter (daihyosen.go) for the same reason as its IsPoolDaihyosenMatchID sibling: a plain substring match would misclassify a regular match in a pool whose name happens to contain "-TB-".
func IsTied ¶
func IsTied(a, b TeamSummary) bool
IsTied reports whether two TeamSummary values match on both IV and PW. This is the trigger condition for daihyosen, a team match that otherwise has a decided IV winner does NOT require daihyosen even when PW is equal.
func RetiredPlayersFromBoutLog ¶
func RetiredPlayersFromBoutLog(boutLog []state.SubMatchResult, teamAName, teamBName string) (retiredA, retiredB map[string]struct{})
RetiredPlayersFromBoutLog walks a bout log and returns, per side, the set of player names that have retired (lost or hikiwake'd out) up to and including the supplied log. The returned maps key off player name; presence == retired.
Helper for callers building AdvanceKachinukiInput.{SideA,SideB} from a roster, they subtract retired names from the initial roster to derive the remaining un-retired queue.
teamAName / teamBName are the parent MatchResult.SideA / SideB (the team names), used to disambiguate which side won each bout.
func SuggestedMaxCourts ¶ added in v0.17.0
SuggestedMaxCourts returns the recommended maximum number of courts for a single-pool competition with numPlayers players. Formula: floor(numPlayers/2) - 1, minimum 1. At this count, every player gets at least one rest slot between fights.
func ValidateCourtCount ¶ added in v0.17.0
ValidateCourtCount checks if numCourts is valid for a single-pool competition with numPlayers players.
Returns error if numCourts > floor(numPlayers/2): courts would sit idle because there are not enough players to fill a round.
The warning case (numCourts == floor(N/2), no rest between fights) is handled exclusively by the frontend, see admin_competition.jsx.
Types ¶
type AdvanceKachinukiInput ¶
type AdvanceKachinukiInput struct {
LastBout state.SubMatchResult
SideA []string
SideB []string
}
AdvanceKachinukiInput is the minimal snapshot AdvanceKachinuki needs. The engine deliberately does NOT load the full match, callers pass the completed bout plus the un-retired roster per team so this function stays free of I/O and trivially unit-testable.
- LastBout: the bout that just completed. Decision-or-Winner determines the advancement path. SideA / SideB names on the bout identify which physical player just played for each team.
- SideA, SideB: remaining un-retired competitors per team in the order they will take the court. SHOULD NOT include the players that just played in LastBout (callers strip retired players before passing the snapshot). The team names themselves are carried on the parent MatchResult, not here.
type AdvanceKachinukiResult ¶
type AdvanceKachinukiResult struct {
Next *state.SubMatchResult
MatchEnded bool
WinningSide string // "A" or "B" when MatchEnded; "" otherwise
Decision string // domain.DecisionKachinukiExhaustion when MatchEnded
// BothExhausted is true only when a hikiwake retired the last player on
// BOTH teams at once (no winner determinable). AdvanceKachinuki cannot
// pick a winner; the caller decides the outcome by phase: a pool/league
// encounter is finalized as a draw, a bracket encounter stays running
// until the operator resolves the tie with a daihyosen.
BothExhausted bool
}
AdvanceKachinukiResult is the engine's verdict.
- Next: when non-nil, the next bout to schedule. Position is set to LastBout.Position + 1; SideA/SideB carry the next pair of player names. Other fields are left zero, the score handler will fill them as the bout is played.
- MatchEnded: true when one team has no remaining un-retired players. Next is nil. WinningSide is "A" or "B"; Decision is domain.DecisionKachinukiExhaustion. Callers should mark the parent MatchResult completed with these values.
- BothExhausted: true when a hikiwake retired the last player on both teams simultaneously. MatchEnded is false; the caller decides the outcome by phase (pool/league draw, bracket daihyosen).
func AdvanceKachinuki ¶
func AdvanceKachinuki(in AdvanceKachinukiInput) AdvanceKachinukiResult
AdvanceKachinuki computes the post-bout transition.
Branches:
- LastBout.Winner names the SideA player → SideA stays on; we pair them against the head of input.SideB.
- LastBout.Winner names the SideB player → SideB stays on; we pair them against the head of input.SideA.
- LastBout is a hikiwake (Decision == domain.DecisionHikiwake or Winner == "" with a recorded decision) → both retire; pair the heads of input.SideA and input.SideB.
- One side's queue empty → MatchEnded=true, the non-empty side wins by exhaustion. BOTH empty (simultaneous exhaustion after a hikiwake) → BothExhausted=true and no winner; the caller finalizes a pool/league encounter as a draw or keeps a bracket encounter running for a daihyosen.
The function is pure: no I/O, no logging on the happy path. Unusual inputs (Winner not matching either side) log a warning so live-tournament operators get a breadcrumb when something downstream silently degraded. Simultaneous exhaustion (BothExhausted) logs a breadcrumb to trace the phase-dispatch flow.
type AlreadyIneligibleError ¶
AlreadyIneligibleError is returned by RecordDecision when the intended loser already carries Eligible:false from a *different* match, indicating two operators on different courts concurrently tried to kiken/fusenpai the same player (CHK047, T105, NFR-010).
func (*AlreadyIneligibleError) Error ¶
func (e *AlreadyIneligibleError) Error() string
type AutoCompleteOutcome ¶
type AutoCompleteOutcome int
AutoCompleteOutcome is the result of a MaybeAutoCompletePools call.
const ( // AutoCompleteNoChange means no transition occurred (matches still pending). AutoCompleteNoChange AutoCompleteOutcome = 0 // AutoCompleteTransitioned means all matches finished and the competition // moved to CompStatusComplete. Callers should broadcast EventCompetitionCompleted. AutoCompleteTransitioned AutoCompleteOutcome = 1 // AutoCompleteTiebreakInjected means all regular pool matches finished but // tied competitors were found. Supplementary ippon-shobu matches were injected // and the competition remains in CompStatusPools. Callers should broadcast // EventMatchUpdated and EventScheduleUpdated. AutoCompleteTiebreakInjected AutoCompleteOutcome = 2 // AutoCompleteKnockoutStarted means the LAST pool of a mixed competition was // just seeded into the knockout bracket: every pool is now resolved, so the // competition moved CompStatusPools → CompStatusPlayoffs (only knockout // matches remain). Callers should broadcast EventCompetitionStarted and // EventScheduleUpdated. AutoCompleteKnockoutStarted AutoCompleteOutcome = 3 // AutoCompletePoolsResolved means one or more (but not all) pools of a mixed // competition were just seeded into the knockout bracket, OR tiebreaker/DH // matches were injected. The competition stays in CompStatusPools while the // remaining pools run, but the bracket changed and knockout matches whose // both sides are now resolved have become SCOREABLE (scheduling is never // gated, court/time can be set on a placeholder match at any time). Callers // should broadcast EventMatchUpdated and EventScheduleUpdated. AutoCompletePoolsResolved AutoCompleteOutcome = 4 // AutoCompleteAwaitingLeagueTiebreak means all regular team-league matches are // done and there is at least one consequential tie (a group of tied teams whose // position range intersects [1..LeagueTiebreakTopN], adjusted for the two-joint- // 3rd-places convention). The competition stays in CompStatusPools; the engine // did NOT auto-inject any DH matches. The operator must use the league-tiebreak // endpoints to either generate tie-breaker matches or accept shared ranks. Until // the operator acts, the competition cannot transition to CompStatusComplete. // Callers should broadcast both EventMatchUpdated (reload standings) and // EventScheduleUpdated (so the UI shows the "awaiting tie-breaker" banner). AutoCompleteAwaitingLeagueTiebreak AutoCompleteOutcome = 5 )
type ChusenGroup ¶ added in v1.0.0
type ChusenGroup struct {
PoolName string
// Teams are the still-tied members in current standings order.
Teams []state.PlayerStanding
// MinPosition is the 1-based finishing position of the best-placed member.
MinPosition int
}
ChusenGroup is a consequential team-pool tie that the daihyosen could not separate: two or more members share the same daihyosen win count (a true win/loss cycle, an all-drawn round, or any other tie in win counts), so the finishing order is still undetermined. Per the rules (running_a_kendo_ tournament.md:181, EKC 6.2.5.1) the last resort is chusen (drawing lots): the operator draws lots and records the order, which persists as a per-pool rank override and lets the competition advance.
This is an INTERNAL engine type, not a wire DTO: the GET /chusen-candidates handler builds its JSON response from a gin.H literal (a "teamNames" array), so these fields are never marshaled directly and carry no json tags.
type ClashWarning ¶ added in v0.17.0
type ClashWarning struct {
OtherCompID string `json:"otherCompId"`
OtherCompName string `json:"otherCompName"`
Date string `json:"date"`
OverlapStart string `json:"overlapStart"` // HH:MM
OverlapEnd string `json:"overlapEnd"` // HH:MM
}
ClashWarning describes a court (shiaijo) scheduling conflict between the queried competition and another competition: they run on the same date, share at least one court, and their time windows overlap.
type CourtBusyError ¶ added in v0.17.0
CourtBusyError is returned when the target court already has a running match. Which competitions are scanned depends on the call site:
- StartMatch (non-tx): scans all competitions via store.RunningMatchOnCourt.
- CheckCrossCompCourtBusy (pre-tx gate): scans all competitions except compID.
- StartMatchTx (tx path): scans only within compID, cross-competition conflicts are caught by CheckCrossCompCourtBusy before the tx begins.
Courts are tournament-global: one physical shiaijo can host only one match at a time regardless of which competition owns it.
func (*CourtBusyError) Error ¶ added in v0.17.0
func (e *CourtBusyError) Error() string
func (*CourtBusyError) Is ¶ added in v0.17.0
func (e *CourtBusyError) Is(target error) bool
type DownstreamKnockoutScoredError ¶ added in v0.17.0
type DownstreamKnockoutScoredError struct {
// Pool is the name of the pool whose re-score was rejected.
Pool string
// Finisher is the name of the pool finisher whose bracket placement
// would be displaced by the re-score.
Finisher string
// MatchID is the ID of the downstream knockout match that has already
// been started (running or completed) with the current finisher as a side.
MatchID string
}
DownstreamKnockoutScoredError is returned when a pool re-score would change a pool finisher who has already been consumed by a started or completed knockout (bracket) match. The operator must reset the knockout match first before correcting the pool result.
mp-e2k1.
func (*DownstreamKnockoutScoredError) Error ¶ added in v0.17.0
func (e *DownstreamKnockoutScoredError) Error() string
func (*DownstreamKnockoutScoredError) Is ¶ added in v0.17.0
func (e *DownstreamKnockoutScoredError) Is(target error) bool
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
func (*Engine) AddDaihyosen ¶
func (e *Engine) AddDaihyosen( compID, matchID string, sideA, sideB TeamSummary, isPool bool, sideAEligible, sideBEligible int, ) (*state.SubMatchResult, error)
AddDaihyosen validates the request to add a daihyosen bout to a team match and returns a SubMatchResult placeholder that the operator fills in via the standard score path.
Validation order (matches handler 400 vs 409 mapping):
- ErrPoolMatch when isPool, daihyosen is knockout-only.
- ErrNotTied when IV or PW differ, operator must score normally.
- ErrInsufficientEligibility when either side has 0 eligible competitors, caller MUST then record a match result with the opposing team as Winner (CHK026 forfeit rule).
The returned SubMatchResult has Position = -1 (a sentinel for "this is a daihyosen bout, not a numbered roster position") and Decision = "daihyosen". The handler appends this to the match's SubResults; the operator subsequently records the rep players + ippon via the score modal.
CHK026: when a team has exactly 1 eligible competitor, that competitor MUST be the rep, the operator selects them in the score modal. The engine does not enforce that single-choice constraint here (it has no view of the lineup); it only guarantees at least one option exists per side.
FR-046, CHK026, T139.
func (*Engine) AdvanceSwissRound ¶
AdvanceSwissRound is the high-level engine wrapper for the POST /swiss/generate-round handler. It:
- Validates that the current round is completed (FR-050d).
- Generates the next round via GenerateSwissRound.
- Appends the new matches to pool-matches.csv (merging with prior rounds, Swiss runs the same persistence shape as pools, but cross-round, so each save carries the cumulative state).
- Atomically bumps SwissCurrentRound on the competition config.
Returns the new round's matches (NOT the merged list) so the handler can broadcast them and the caller of the API gets a clean per-round payload.
All store mutations run under the per-competition lock via the store atomic primitives.
func (*Engine) CalculatePoolStandings ¶
func (*Engine) CheckCrossCompCourtBusy ¶ added in v0.17.0
CheckCrossCompCourtBusy checks whether the court assigned to matchID is currently occupied by a running match in a different competition. It MUST be called before entering WithTransaction for compID: calling store.RunningMatchOnCourt while holding a per-comp write lock risks a circular-wait deadlock if another competition is simultaneously in its own WithTransaction (both goroutines try to read-lock each other's mutex).
func (*Engine) CheckEligibility ¶
CheckEligibility consults the competitor-status store for compID and returns *IneligibleCompetitorError for the first playerID found with Eligible: false; nil when all playerIDs are eligible (or unknown to the store, which means default-eligible per FR-034).
FR-035.
func (*Engine) CheckKachinukiPrematureCompletion ¶ added in v1.0.0
func (e *Engine) CheckKachinukiPrematureCompletion(compID, matchID string, result *state.MatchResult) error
CheckKachinukiPrematureCompletion is the score handler's pre-write safety net (ACID: no silent drops, no silent acceptance of a bogus final). A status=completed write on a kachinuki team match is only legitimate when one of these holds:
- the write is a correction (the stored match is already completed),
- the patch carries a daihyosen sub-result (position -1), the sanctioned tied-after-exhaustion resolution,
- the match-level decision is a withdrawal/default (kiken*/fusenpai/fusensho), which finalizes without playing out the roster,
- the roster snapshot derived from the incoming bout log says at least one side is exhausted.
Otherwise it returns ErrKachinukiPrematureCompletion (handler: 409). Non-kachinuki competitions and non-completed writes always pass. Must be called OUTSIDE the score transaction: the store loads here acquire the per-comp lock themselves.
func (*Engine) ChusenCandidates ¶ added in v1.0.0
func (e *Engine) ChusenCandidates(compID string) ([]ChusenGroup, error)
ChusenCandidates returns the consequential team-pool ties that the daihyosen left undetermined and that therefore need a chusen (drawing lots). It is the single source of truth for "which groups still need an operator lots-draw", used by the GET /chusen-candidates endpoint. Empty (not an error) when the competition is not a team comp in the pools stage, or no such group exists.
Pools are returned in name order for stable output.
func (*Engine) CurrentSwissRoundCompleted ¶
CurrentSwissRoundCompleted reports whether every match in the currently-active Swiss round is completed. Returns true when the current round is 0 (not started, vacuously "complete enough" so the first round can be generated) or every match in pool-matches.csv whose ID parses to the current round has Status == Completed.
FR-050d. Used by the POST /swiss/generate-round handler as the pre-condition gate.
func (*Engine) DetectClashesForCompetition ¶ added in v0.17.0
func (e *Engine) DetectClashesForCompetition(compID string) ([]ClashWarning, error)
DetectClashesForCompetition returns the court-scheduling clashes between the competition identified by compID and every other competition. Two competitions clash when they share a date, share at least one court, and their [start, start+footprint) windows overlap. Footprint is max(estimated duration, MinClashFootprintMinutes).
Returns an empty (non-nil) slice when there are no clashes. A competition that cannot be placed on a timeline, no date or an unparseable start time, is skipped rather than reported. Clashes are sorted by the other competition's name for a stable UI/test order.
func (*Engine) DiscardDraw ¶ added in v0.17.0
DiscardDraw discards the generated draw for a draw-ready competition, deleting the draw artifacts and resetting the competition to Setup. Returns an error when the competition is not in draw-ready state.
Ordering rationale: files are deleted BEFORE the status flip. While the competition is still draw-ready, GenerateDraw rejects new requests, so no concurrent caller can generate fresh artifacts during the deletion window. If we flipped to Setup first, a concurrent GenerateDraw could start, write new artifacts, commit draw-ready, and then our deferred deletes would erase the freshly generated files, leaving draw-ready with no artifacts.
func (*Engine) EstimateScheduleForCompetition ¶ added in v0.17.0
func (e *Engine) EstimateScheduleForCompetition(compID string) (ScheduleEstimate, error)
EstimateScheduleForCompetition returns a pre-draw ScheduleEstimate for the competition identified by compID. It loads the competition and participant roster, derives pool + playoff match counts via helper.EstimateMatchCounts, and delegates to EstimateForCounts.
func (*Engine) ExportCompetitionXlsx ¶
func (*Engine) ExportTournamentWorkbooks ¶ added in v0.17.0
func (e *Engine) ExportTournamentWorkbooks(tmpDir string, compIDs ...string) ([]pdf.SourceWorkbook, error)
ExportTournamentWorkbooks renders every competition in the tournament to a bracket XLSX in tmpDir and returns the corresponding pdf.SourceWorkbook list, ready to feed pdf.Generator. This is the bridge from live mobile-app state to the PDF pipeline, shared by the CLI `print --tournament-data` mode and the mobile-app Export-PDFs endpoint.
Each competition's display name becomes the title-page text; team competitions (TeamSize > 0 or Kind == "team") are flagged so the Tags group can exclude them. compIDs, when non-empty, restricts export to those competitions; otherwise all competitions are exported.
func (*Engine) GenerateDraw ¶ added in v0.17.0
GenerateDraw generates pools/bracket/Swiss-r1 for a Setup competition and transitions it to CompStatusDrawReady without starting the competition. The operator can preview, discard, and regenerate the draw before calling StartCompetition to enter the running state.
func (*Engine) GenerateLeagueTiebreakMatches ¶ added in v0.17.0
func (e *Engine) GenerateLeagueTiebreakMatches(compID string, tiedTeamNames []string) ([]state.MatchResult, error)
GenerateLeagueTiebreakMatches generates the round-robin daihyosen (tie-breaker) matches for a specific set of tied teams in a team-league competition. It is the operator-triggered path, called by the POST /league-tiebreak handler after it has validated the selection against LeagueTiebreakCandidates (this function does NOT re-validate consequentiality, the handler is the gate).
The matches use the "Pool X-DH-N" ID format so they are recognized by the existing IsPoolDaihyosenMatchID predicate and routed to the DH score editor. Idempotent: pairs that already exist in the store are skipped. For league competitions it operates on the single league pool.
func (*Engine) GenerateSchedule ¶
func (*Engine) GenerateSwissRound ¶
GenerateSwissRound builds the matches for round `roundNumber` of the Swiss-format competition identified by compID. Returns only the new round's matches, the caller is responsible for merging them into the persisted pool-matches.csv. The caller-merge convention mirrors the HTTP handler shape: POST /swiss/generate-round loads existing matches, calls this method, appends, and saves under the store transaction.
Algorithm (FR-050b, FR-050c, FR-050f):
- Round 1: fold pairing when seeds are present (1 vs N, 2 vs N-1, …); deterministic-random pairing otherwise. The deterministic RNG is keyed on compID so repeated calls produce the same round 1, important for retry semantics on transient I/O failures.
- Round N > 1: group active players by win count (desc); within each group pair top with bottom while avoiding rematches. When a player can't be paired without a rematch the algorithm pulls the next-best fallback from an adjacent group.
- Kiken / fusenpai exclusion: players with CompetitorStatus {Eligible: false} are removed from the active pool before pairing.
- Bye handling: if the active-player count is odd, the lowest- ranked player who has not yet had a bye (or the lowest-ranked overall if all players already had byes) receives a bye, auto-completed win, zero points scored. The bye-resolution order is round-by-round within the lowest win-count group because giving a bye to a top-of-table player would distort the win race.
Courts are assigned round-robin from comp.Courts. Per-court time slots are populated via assignPoolMatchSlots so the returned matches land on the correct schedule cells.
func (*Engine) GetBracketRanking ¶
GetBracketRanking returns the player who achieved rank in the bracket of compID. Supported ranks: 1 (winner), 2 (finalist), 3-4 (semi-final losers). Full player data (dojo, displayName) is resolved from the source competition's participants.
func (*Engine) GetPoolRanking ¶
GetPoolRanking returns the player who achieved rank in the pool standings of compID. If multiple pools exist, SourceRank is treated as a global index across all pools ordered by pool name (e.g., Rank 1 = Winner of Pool 1, Rank 2 = Winner of Pool 2).
func (*Engine) InjectPoolDaihyosenMatches ¶
func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult, error)
InjectPoolDaihyosenMatches inspects team-pool standings for compID after all regular pool matches are complete. For every tied group (same weighted Points encoding all 8 ranking criteria), it generates a round-robin of daihyosen (representative) bouts, appends them to the pool-matches store, and regenerates the schedule. Returns the newly injected matches (nil when there are no ties or all DH pairs already exist).
Pool-DH match IDs use the "Pool X-DH-N" format. They are scored as individual (one representative per side) rather than as full team bouts; their results are applied to standings via the DH secondary sort in computeStandings. The frontend routes these to the individual ScoreEditorModal by detecting the DH prefix in compMatches.
func (*Engine) InjectTiebreakerMatches ¶
func (e *Engine) InjectTiebreakerMatches(compID string) ([]state.MatchResult, error)
InjectTiebreakerMatches inspects all pool standings for compID after regular pool matches are complete. For every tied group (same Points after the full cascade), it generates a round-robin of ippon-shobu tiebreaker matches, appends them to the pool-matches CSV, and regenerates the schedule. Returns the newly injected matches (nil when there are no ties or all TB pairs already exist).
func (*Engine) KachinukiDetailMatches ¶ added in v1.0.0
func (e *Engine) KachinukiDetailMatches(id string) ([]helper.KachinukiMatchDetail, error)
KachinukiDetailMatches returns the bout-by-bout kachinuki detail for a competition, or an empty slice for fixed-format/individual comps. Exported so sibling workbook builders (internal/export.BuildResultsWorkbook, the "Download results" path) can emit the Kachinuki Detail sheet without duplicating the collection logic that Engine.ExportCompetitionXlsx uses.
func (*Engine) LeagueStandings ¶ added in v1.0.0
func (e *Engine) LeagueStandings(compID string) ([]state.PlayerStanding, error)
LeagueStandings returns a league competition's standings as a single rank-ordered slice, mirroring SwissStandings. A league runs one round-robin group, so this returns that group's PlayerStandings (already rank-ordered by CalculatePoolStandings, with daihyosen / tie-break / override state applied).
Leagues are NOT pools: this dedicated read is what the league standings surface consumes, so the league UI never routes through the pool path. A non-league competition yields a NotFoundError so the endpoint 404s rather than leaking pool standings.
func (*Engine) LeagueTiebreakCandidates ¶ added in v0.17.0
LeagueTiebreakCandidates returns the consequential tied groups in a team-league competition after all regular pool matches are complete. A group is "consequential" when it intersects the tie-break band [1..LeagueTiebreakTopN] and is not covered by the LeagueTwoThirdPlaces exemption (see isConsequentialTie).
Returns an empty slice (not an error) when:
- The competition is not a team league (Format != "league" or TeamSize == 0).
- There are no ties in the standings.
- All ties fall outside the consequential band.
This function is the single source of truth for "are there ties that need an operator decision?" and is used by both MaybeAutoCompletePools (to block premature completion) and Phase 3b's operator endpoints (to list which teams need a tie-breaker).
The league standings come from a single implicit pool ("Pool A" by convention for single-pool leagues). All tied groups across all pools are evaluated; in practice a league has exactly one pool.
func (*Engine) MaybeAdvanceKachinuki ¶
MaybeAdvanceKachinuki runs the post-score side effect for a kachinuki team match.
The score endpoint (handlers_match.go) calls this AFTER RecordMatchResult* has persisted the operator's bout. Steps:
- Load the competition; bail out as no-op if it's not a kachinuki team competition.
- Load the just-recorded MatchResult; bail if its last SubResults entry has no final outcome (still in progress).
- Build the remaining-roster snapshot per side from the saved TeamLineup (GAP 1/2a), falling back to the unique player names seen in the bout log when no lineup is saved. The exhaustion-end and hikiwake-after-empty-queue cases run off this roster data.
- Pass to AdvanceKachinuki. When it returns Next, append the bout to SubResults and persist (status stays Running). When it returns MatchEnded, finalize the parent match (Status=Completed, Decision=kachinuki-exhaustion, Winner=team-name).
Returns (changed, error). `changed` indicates whether SubResults or the parent match was mutated, handler uses it to decide whether to emit an additional match-updated SSE event with the freshly-derived bout list.
FR-044, T135, T137.
func (*Engine) MaybeAutoCompletePools ¶
func (e *Engine) MaybeAutoCompletePools(compID string) (AutoCompleteOutcome, error)
MaybeAutoCompletePools advances a competition past its pool phase after a pool score:
- League format (individual) → transitions to CompStatusComplete once every pool match is recorded as completed, with supplementary ippon-shobu tiebreaker matches auto-injected for tied competitors.
- League format (team) → transitions to CompStatusComplete once every pool match is done AND there are no consequential ties requiring a tie-breaker. If there are consequential ties, AutoCompleteAwaitingLeagueTiebreak is returned and the competition stays in CompStatusPools; NO DH matches are auto-injected. The operator decides whether to run a tie-breaker (Phase 3b). If all ties are non-consequential (below the tie-break band or covered by the two-thirds rule), the league completes with shared ranks.
- Mixed format → delegates to advanceMixedPools, which seeds each COMPLETED pool's finishers into the in-place knockout bracket incrementally (no separate playoffs competition, no manual "start knockout" step), and flips the competition to CompStatusPlayoffs once the LAST pool has been seeded. Knockout matches become scoreable per-match as their feeder pools finish, there is no wait for the whole pool phase.
The function is a no-op for any other format or status.
Atomic: the league status flip runs inside state.Store.UpdateCompetitionChanged. The mixed path delegates to advanceMixedPools, which takes its own per-comp locks; that is safe because MaybeAutoCompletePools is NOT inside an open transform at that point.
func (*Engine) OverrideBracketWinner ¶
func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName string, modifiedAt int64) (bool, error)
OverrideBracketWinner atomically loads the bracket, locates the target match, sets the winner + IsOverridden + Status, propagates the winner to subsequent rounds, and saves. Same UpdateBracket primitive as recordBracketMatchResult and withBracketMatch, the entire find + mutate + propagate + save sequence runs under the per-competition lock, so a concurrent bracket score / court / time update (also under the same lock via the atomic primitives) can't land between our load and save and have its mutation clobbered.
Uses the same UpdateBracket atomic primitive as the rest of the scoring path to avoid the LoadBracket + mutate + Save TOCTOU window. Returns (applied bool, err error). applied is true when the write landed; false when it was dropped by the timestamp LWW guard (stale reconnect replay). A false return still carries nil err so the caller can respond 200 with {"applied":false} without broadcasting (finding 7). Returning errLWWDropped from the mutate callback causes UpdateBracket to skip the disk save, avoiding a spurious write of an unchanged bracket (finding 8).
func (*Engine) RecordDecision ¶
func (e *Engine) RecordDecision(compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, force bool) (*state.MatchResult, *domain.CompetitorStatus, error)
RecordDecision auto-fills the scoreline from decision/decisionBy/encho and persists the result via RecordMatchResultWithIneligibility. The canonical SideA=Aka / SideB=Shiro mapping (CLAUDE.md) is used to translate decisionBy → which side loses/forfeits: winner gets ○○ (regulation) or ○ (encho), loser gets nothing.
When the match already has a kiken/fusenpai decision recorded (the "undo" path, T103/CHK024) the engine enforces the contracts/match-decisions.md §Decision lock & undo rule: if any subsequent match involving either prior participant has started since the original decision was recorded, the engine returns ErrDecisionLocked unless force is true. On a successful overwrite where the prior loser is no longer the new loser, the prior loser's CompetitorStatus is restored to Eligible: true and surfaced as the returned status so the handler can broadcast the change.
Returns the persisted MatchResult and the most-recent CompetitorStatus change (new ineligibility OR restored eligibility), or nil when no status change applies.
T090, T103, contracts/match-decisions.md §POST /decision.
func (*Engine) RecordDecisionTx ¶
func (e *Engine) RecordDecisionTx(tx state.StoreTx, compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, force bool) (*state.MatchResult, *domain.CompetitorStatus, error)
RecordDecisionTx is the tx-aware twin of RecordDecision. Same contract, auto-fills the scoreline, runs the T103 lock + T105 concurrent-kiken checks, persists the result, restores prior-loser eligibility on undo, all inside ONE per-comp lock acquire.
T156.
func (*Engine) RecordMatchResult ¶
func (*Engine) RecordMatchResultWithIneligibility ¶
func (e *Engine) RecordMatchResultWithIneligibility(compId string, matchId string, result *state.MatchResult) (*domain.CompetitorStatus, error)
RecordMatchResultWithIneligibility is the variant used by the score and decision handlers that need to broadcast the `competitor-status-updated` SSE event after a kiken/fusenpai is recorded. It returns the new CompetitorStatus (or nil when none was written) alongside any error.
The match-score persistence semantics are identical to RecordMatchResult; only the side-effect status is surfaced for the caller's broadcast. Side-effect write failures are still non-fatal, the function returns (nil, nil) and logs.
T085/T092.
func (*Engine) RecordMatchResultWithIneligibilityTx ¶
func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, matchID string, result *state.MatchResult) (*domain.CompetitorStatus, error)
RecordMatchResultWithIneligibilityTx is the tx-aware twin of RecordMatchResultWithIneligibility. The K3/CHK047 partial-write rollback path replays the prior result via the same tx so the rollback also runs inside the single lock acquire.
On AlreadyIneligibleError the caller's WithTransaction body should propagate the error through to the handler, there's no need (and no way) to roll back via a separate tx here because the rollback write is part of THIS tx's mutations.
T156.
func (*Engine) ReinstateCompetitor ¶
func (e *Engine) ReinstateCompetitor(compID, playerID string) (*domain.CompetitorStatus, error)
ReinstateCompetitor restores eligibility for a competitor who was withdrawn via kiken-injury (FIK Art. 30). The status must exist, be Eligible: false, and have Reinstateable: true (set by kiken-injury). Voluntary kiken (Art. 31) and fusenpai statuses are not reinstateable, the endpoint returns an error.
The check-and-set runs under WithTransaction (K2/CHK047) to close the TOCTOU window between reading the Reinstateable flag and writing the reinstated status.
func (*Engine) ReplaceParticipantInDraw ¶ added in v0.17.0
func (e *Engine) ReplaceParticipantInDraw( compID string, oldName, oldDojo, oldDisplayName string, newName, newDojo, newDisplayName string, ) (warnings []string, err error)
ReplaceParticipantInDraw cascades a participant name/dojo/displayName change through draw artifacts (pools.csv, bracket.json, pool-matches.csv) for a draw-ready competition. Called AFTER UpdateParticipant has already updated participants.csv and seeds.csv.
Returns warnings (e.g. dojo conflicts) and an error on failure.
Transaction safety: all three files (pools.csv, bracket.json, pool-matches.csv) are updated under a single Store.WithTransaction lock. bracket.json and pool-matches.csv are WAL-staged. pools.csv is written directly (not WAL-staged) but still under the same lock, so no concurrent StartCompetition can interleave between any of the writes.
func (*Engine) ResolveQualifiedPools ¶ added in v0.17.0
ResolveQualifiedPools incrementally seeds the in-place knockout bracket of a mixed (Pools + Knockout) competition. For EVERY pool whose results are final it writes that pool's real finishers into the bracket slots their finalist placeholders ("Pool A-1st", …) occupy, and resolves any bye those finishers inherit. Pools still in progress keep their placeholders. There is NO all-pools gate: a knockout match becomes playable the moment both its feeder pools have finished, while other pools are still running.
Resolution is RE-SEEDABLE, not a one-shot string replace. It recomputes the deterministic placeholder template (the same GenerateFinals → CreateBalancedTree → ApplyPoolAdjustments → TreeToLeafArray pipeline used at draw) and resolves the running bracket against it BY POSITION. So if an operator re-scores a completed pool match after that pool was already seeded, changing the 1st/2nd finisher, the new finisher overwrites the stale name in the same slot, instead of being silently dropped (the live side no longer holds the placeholder string, but the template still does). Pools and PoolWinners are fixed after the draw, so the template's shape is identical to the running bracket's. The bracket's court/time slots are assigned at draw time and never change here, only competitor labels.
Known limitation (mp-e2k1): re-seeding repaints round-0 leaves and bye-propagated sides, but does NOT invalidate a DOWNSTREAM knockout match that was already scored during the pool phase if its feeder pool is later re-scored to a different finisher.
Returns (resolvedNow, allResolved): how many bracket sides changed THIS call, and whether the bracket now has zero pool-origin placeholders left (every pool seeded). No-op (0, false, nil) for non-mixed competitions, standalone playoffs brackets carry no pool placeholders.
func (*Engine) RevertMatchToQueue ¶ added in v1.0.0
RevertMatchToQueue reverts a running match back to the scheduled (queued) state, clearing any partial score so the bout can be restarted correctly. It is idempotent for already-scheduled matches (no-op success). Completed matches return ErrMatchAlreadyCompleted (HTTP 409); the operator must use the score editor to correct a recorded result instead.
Modelled on UpdateMatchCourt: pool-match first, bracket-match fallback, using the same atomic withPoolMatch/withBracketMatch primitives so the entire load+mutate+save runs under the per-competition lock.
func (*Engine) StartCompetition ¶
StartCompetition starts a competition. When called on a draw-ready competition it transitions directly to running (no regeneration). When called on a setup competition it generates the draw first then transitions, preserving the single-click "Start" UX.
See runDrawPipeline for pipeline limitations (pre-existing).
func (*Engine) StartMatch ¶
StartMatch gates the scheduled → running transition by checking every participant's competitor-status and ensuring that no participant is already Running in a different match within the same competition (the simultaneity gate, Phase 2c).
It returns *IneligibleCompetitorError (which matches errors.Is(err, ErrIneligibleCompetitor)) when any participant has Eligible: false or is already fighting elsewhere; nil when the match may proceed.
The status transition itself remains with the score handler, this method is the pre-flight gate.
FR-035, T084.
func (*Engine) StartMatchTx ¶
StartMatchTx is the tx-aware FR-035 gate. Same contract as StartMatch: returns *IneligibleCompetitorError when any participant in matchID is marked ineligible from a *different* match or is currently Running in a different match (Phase 2c simultaneity gate). The undo-path is permitted (status with MatchID==matchID is skipped).
The score handler wraps RecordMatchResultWithIneligibilityTx with this check so a fought / hikiwake score on a match whose participants include someone previously ineligible is rejected before any disk write. Kiken/fusenpai decisions go through RecordDecisionTx, which intentionally bypasses this gate, they ARE the act of recording a new withdrawal.
func (*Engine) SwissStandings ¶
func (e *Engine) SwissStandings(compID string) ([]state.PlayerStanding, error)
SwissStandings computes the cumulative standings for the Swiss competition compID. Ranks are assigned by:
- Wins (descending)
- Points scored (descending), total ippons given across all completed Swiss matches the player participated in
- Head-to-head (descending): when (1) and (2) tie, the player who won the direct match between them ranks higher
- Stable name order (alphabetical) as a final deterministic tiebreak, guarantees idempotent output
Returns one entry per participant (including byes), with Rank set 1..N. Excludes participants with no Swiss matches recorded (a future round may still pair them; their absence from the file is a "not yet played" signal, not a "ranked last" one), but the participants are still emitted with zeros so the standings page can render the full roster.
FR-050e.
func (*Engine) UpdateMatchCourt ¶
type EstimateInput ¶
type EstimateInput struct {
MatchDurationClockMinutes float64
Multiplier float64
NumMatches int
NumCourts int
TeamSize int
BoutsPerTeamMatch int
SlowestCourtBufferPct int
CeremonyMinutes int
}
EstimateInput holds the parameters EstimateSchedule consumes. All fields are required except SlowestCourtBufferPct (no buffer when 0) and CeremonyMinutes (no ceremony when 0). TeamSize=0 selects the individual-match branch; TeamSize>0 with BoutsPerTeamMatch>0 selects the team-match branch and scales per-match duration by bouts plus an inter-bout transition allowance (~1 minute per switch).
FR-055, FR-058: the multiplier converts on-clock minutes to elapsed minutes; team-match duration scales linearly with bouts plus a per-switch transition.
type IneligibleCompetitorError ¶
IneligibleCompetitorError wraps ErrIneligibleCompetitor with the player that failed the eligibility check.
func (*IneligibleCompetitorError) Error ¶
func (e *IneligibleCompetitorError) Error() string
func (*IneligibleCompetitorError) Is ¶
func (e *IneligibleCompetitorError) Is(target error) bool
type NotFoundError ¶
type NotFoundError struct {
Msg string
}
NotFoundError represents a missing resource. Handlers should return HTTP 404.
func (*NotFoundError) Error ¶
func (e *NotFoundError) Error() string
type ScheduleEstimate ¶
type ScheduleEstimate struct {
TotalDurationMinutes int `json:"totalDurationMinutes"`
PerCourtMinutes []int `json:"perCourtMinutes"`
CeremonyMinutes int `json:"ceremonyMinutes"`
}
ScheduleEstimate is the wire response for GET /api/schedule/estimate and the return type of EstimateSchedule. All durations are in minutes, rounded to the nearest integer.
PerCourtMinutes has length == in.NumCourts (>=1 after clamping) and each entry is the estimated elapsed minutes that one court runs match play (excluding ceremonies). TotalDurationMinutes is the slowest court plus CeremonyMinutes, i.e. the earliest the operator can expect the final medal ceremony to begin.
NOTE the "excluding ceremonies" wording above describes the EstimateSchedule producer (raw scalars, no break model). The other producer, EstimateForCounts, fills PerCourtMinutes from a per-court clock cursor that INCLUDES the OpeningBlock offset and any LunchBlock dead-time, see its doc comment.
data-model §5/§6.
func EstimateForCounts ¶ added in v0.17.0
func EstimateForCounts(poolCount, playoffCount int, comp *state.Competition, tournament *state.Tournament) ScheduleEstimate
EstimateForCounts returns a ScheduleEstimate for a pre-draw competition (no generated matches yet) given the expected number of pool matches and playoff matches. It reuses the slot-model primitives (perMatchElapsedMinutes, skipCeremonyBlocks) so per-match and in-day break (OpeningBlock/LunchBlock) math match the post-draw path, but the aggregate intentionally diverges in two documented ways (the buffer and phase-sequencing notes below), so it is NOT a byte-for-byte equal. (ClosingBlock is handled differently, surfaced as CeremonyMinutes here, ignored by the assigners; see below.)
Unit reconciliation: the slot model advances clock times (time.Time), while ScheduleEstimate.TotalDurationMinutes is a duration in minutes. This function defines TotalDurationMinutes = round(maxCourtCursor - dayStart), where dayStart is comp.StartTime (the same anchor the slot assigners use). Each court's cursor is initialised to dayStart+OpeningBlock, so the returned duration INCLUDES the opening-block offset. PerCourtMinutes entries are each court's individual elapsed duration, and, NOTE, unlike the ScheduleEstimate struct docstring ("match play (excluding ceremonies)", which describes the EstimateSchedule producer), these entries INCLUDE the OpeningBlock offset and any LunchBlock dead-time. They are NOT match-time-only. (Only the slowest-court buffer is applied to match time alone; see below.)
Buffer divergence (intentional): EstimateForCounts applies tournament.SlowestCourtBufferPct because it is a predictive, pre-draw estimate, the slowest court will likely run over the mean. The buffer is applied to MATCH time only (matchMin), not to the fixed OpeningBlock offset or LunchBlock dead-time, which have no runtime variance to pad, matching EstimateSchedule's semantics. The post-draw slot assigners (assignPoolMatchSlots / assignBracketMatchSlots) do NOT apply the buffer at all because a real, assigned schedule needs no extra padding. So do NOT assert RAW cross-regime equality, but the buffered relationship (EstimateForCounts ≈ slot duration × (1 + buffer/100)) IS valid, and is exactly what TestEstimateForCountsVsSlotAssigner_Balanced checks.
CeremonyMinutes is populated from tournament.ClosingBlock. The OpeningBlock is applied as a pre-loop per-court start offset (cursor initialised to dayStart+OpeningBlock); the LunchBlock is applied per match via the shared skipCeremonyBlocks helper. ClosingBlock is not entered by the cursor, it is surfaced only as CeremonyMinutes.
Phase sequencing (intentional, and a SECOND divergence from the post-draw path): each court runs its pool matches and THEN its playoff matches on the same advancing cursor, pools-then-playoffs, which is the realistic order (playoff seeding needs pool results). The post-draw slot assigners (assignPoolMatchSlots / assignBracketMatchSlots) are invoked as two separate calls that EACH re-anchor to dayStart+OpeningBlock, so they OVERLAP the two phases in clock time. A post-draw estimate must therefore SEQUENCE the two phases rather than max() them, but it must add the OpeningBlock offset ONCE, not once per phase. Summing the raw cursor durations would double-count OpeningBlock (each cursor = dayStart + OpeningBlock + match-time); instead sum the per-phase match durations and add OpeningBlock a single time. See mp-zoh.
Negative counts are clamped to 0, the helper is exported and likely fed derived/user inputs (mp-zoh), and a negative count would otherwise make matchMin negative and yield a nonsensical (even negative) duration.
Returns a zero ScheduleEstimate only when comp is nil; an empty courts list defaults to a single court (matching the assigners and EstimateSchedule).
func EstimateSchedule ¶
func EstimateSchedule(in EstimateInput) ScheduleEstimate
EstimateSchedule computes the total elapsed-minute estimate for a match set given clock duration, multiplier, court count, optional team-match bout count, slowest-court buffer %, and ceremony block.
Algorithm:
- perMatchMin = perMatchElapsed(clockMin, multiplier, bouts)
- totalMin = perMatchMin * numMatches
- perCourt = totalMin / numCourts (clamped numCourts >= 1)
- perCourt *= (1 + buffer/100)
- total = round(perCourt) + ceremonyMinutes
FR-055, FR-057, FR-058, FR-059, data-model §5.
Breaks (OpeningBlock, LunchBlock, ClosingBlock) are NOT modelled here because EstimateInput carries only raw scalars, the stateless handler has no competition/tournament context. Use EstimateForCounts when per-comp, break-aware estimation is needed.
type SwissRoundNotCompletedError ¶
SwissRoundNotCompletedError is returned by AdvanceSwissRound when the current round still has un-completed matches. Handlers should map this to HTTP 409.
FR-050d.
func (*SwissRoundNotCompletedError) Error ¶
func (e *SwissRoundNotCompletedError) Error() string
type TeamSummary ¶
type TeamSummary struct {
// IndividualWins is the count of sub-bouts the team won outright
// (IV in the team scoreline). Draws do NOT contribute here.
IndividualWins int
// PointsWon is the total number of ippons the team scored across
// all sub-bouts (PW in the team scoreline). Hansoku-derived ippons
// follow whichever side they were credited to in the sub-result.
PointsWon int
}
TeamSummary captures the two tie-breaker metrics relevant to the daihyosen decision. Fields mirror the corresponding state.PlayerStanding columns produced by computeStandings, callers building this from a completed team-match's SubResults can do so directly without going through the standings cache (see ComputeTeamSummary).
FR-046, data-model §1.
type TiedGroup ¶ added in v0.17.0
type TiedGroup struct {
// Teams holds the standings entries of all tied members, in the order
// produced by detectPoolTies (which preserves the sorted standings order).
Teams []state.PlayerStanding
// MinPosition is the 1-based rank of the best-placed team in the group.
// For a group starting at index i (0-based) in the sorted standings,
// MinPosition == i+1.
MinPosition int
// MaxPosition is the 1-based rank of the worst-placed team in the group.
// For a group of n teams starting at index i, MaxPosition == i+n.
MaxPosition int
}
TiedGroup describes a group of teams that are tied on all official ranking criteria at the end of league play. MinPosition and MaxPosition are the 1-based finishing positions that the tied teams collectively occupy (e.g. a two-way tie at 2nd place gives MinPosition=2, MaxPosition=3).
type ValidationError ¶
type ValidationError struct {
Msg string
}
ValidationError represents a client-caused precondition or input failure. Handlers typically return HTTP 400, but may return HTTP 409 when the failure is a state conflict (e.g. reinstatement of a non-reinstateable competitor).
func (*ValidationError) Error ¶
func (e *ValidationError) Error() string
Source Files
¶
- bracket.go
- bracket_result.go
- chusen.go
- competition.go
- court_validation.go
- daihyosen.go
- eligibility.go
- engi.go
- engine.go
- errors.go
- estimate_schedule.go
- export.go
- kachinuki.go
- kachinuki_export.go
- knockout.go
- league_schedule.go
- league_standings.go
- league_tiebreak.go
- pdf_export.go
- pools.go
- ranking.go
- replace_participant.go
- schedule.go
- schedule_clash.go
- scheduler_slots.go
- scoring.go
- scoring_tx.go
- swiss.go
- tiebreaker.go