engine

package
v0.16.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 24, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package engine — daihyosen (representative-bout) play-off 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 — 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 / maybeLockTeamLineupsForRound. 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 / LockTeamLineupsForRound. 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 + lineup-freeze 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

View Source
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).

Variables

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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

func FilterRemaining(roster []string, retired map[string]struct{}) []string

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

func IsPoolDaihyosenMatchID(matchID string) bool

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

func IsPoolMatchID(matchID string) bool

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

func IsTiebreakerMatchID(matchID string) bool

IsTiebreakerMatchID reports whether matchID identifies a supplementary ippon-shobu tiebreaker match (IDs of the form "Pool X-TB-N").

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.

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
}

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.

func AdvanceKachinuki

func AdvanceKachinuki(in AdvanceKachinukiInput) AdvanceKachinukiResult

AdvanceKachinuki computes the post-bout transition.

Branches:

  1. LastBout.Winner names the SideA player → SideA stays on; we pair them against the head of input.SideB.
  2. LastBout.Winner names the SideB player → SideB stays on; we pair them against the head of input.SideA.
  3. LastBout is a hikiwake (Decision == domain.DecisionHikiwake or Winner == "" with a recorded decision) → both retire; pair the heads of input.SideA and input.SideB.
  4. Either side's queue is empty → MatchEnded=true, the non-empty side wins by exhaustion. If BOTH are empty (no one left to advance after a hikiwake), Side A is treated as the winner defensively — log + return — but this is an unusual path because the caller should have detected the previous-bout exhaustion first.

The function is pure: no I/O, no logging on the happy path. Unusual inputs (Winner not matching either side, all-empty queues after a hikiwake) log a warning so live-tournament operators get a breadcrumb when something downstream silently degraded.

type AlreadyIneligibleError

type AlreadyIneligibleError struct {
	PlayerID string
	MatchID  string
	Reason   string
}

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
)

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

func New

func New(store *state.Store) *Engine

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):

  1. ErrPoolMatch when isPool — daihyosen is knockout-only.
  2. ErrNotTied when IV or PW differ — operator must score normally.
  3. 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

func (e *Engine) AdvanceSwissRound(compID string) ([]state.MatchResult, int, error)

AdvanceSwissRound is the high-level engine wrapper for the POST /swiss/generate-round handler. It:

  1. Validates that the current round is completed (FR-050d).
  2. Generates the next round via GenerateSwissRound.
  3. 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).
  4. 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 (e *Engine) CalculatePoolStandings(compId string) (map[string][]state.PlayerStanding, error)

func (*Engine) CheckEligibility

func (e *Engine) CheckEligibility(compID string, playerIDs []string) error

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) CurrentSwissRoundCompleted

func (e *Engine) CurrentSwissRoundCompleted(compID string) (bool, error)

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) ExportCompetitionXlsx

func (e *Engine) ExportCompetitionXlsx(id string) ([]byte, error)

func (*Engine) GenerateSchedule

func (e *Engine) GenerateSchedule(compID string) error

func (*Engine) GenerateSwissRound

func (e *Engine) GenerateSwissRound(compID string, roundNumber int) ([]state.MatchResult, error)

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

func (e *Engine) GetBracketRanking(compID string, rank int) (*domain.Player, error)

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

func (e *Engine) GetPoolRanking(compID string, rank int) (*domain.Player, error)

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) MaybeAdvanceKachinuki

func (e *Engine) MaybeAdvanceKachinuki(compID, matchID string) (bool, error)

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:

  1. Load the competition; bail out as no-op if it's not a kachinuki team competition.
  2. Load the just-recorded MatchResult; bail if its last SubResults entry has no final outcome (still in progress).
  3. Build the remaining-roster snapshot per side. The Slice-7.C first-cut leaves the FULL roster lookup to the lineup slice — for now we treat the (currently-unset) Player.Side rosters as unavailable and only act on the per-player winner path that keeps the previous bout's stayer on for the next position. The exhaustion-end and hikiwake-after-empty-queue cases need real roster data and short-circuit with a no-op + log line.
  4. 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.

TODO(slice-7.B/D): once team-lineup persistence (state/team_lineup.go) + scheduling lands, replace the roster shortcut with a real lookup so the exhaustion-end branch works without operator intervention. FR-044, T135, T137.

func (*Engine) MaybeAutoCompletePools

func (e *Engine) MaybeAutoCompletePools(compID string) (AutoCompleteOutcome, error)

MaybeAutoCompletePools transitions a pools-format competition from CompStatusPools to CompStatusComplete when every pool match has been recorded as completed. It is a no-op for any other format or status, or when at least one pool match is still scheduled/running.

When all regular pool matches are done but tied competitors remain, supplementary ippon-shobu tiebreaker matches are injected and AutoCompleteTiebreakInjected is returned instead of transitioning.

Atomic: the status check and the save run inside state.Store.UpdateCompetitionChanged so a concurrent invalidate-vs-auto-complete pair can't lose either mutation.

func (*Engine) OverrideBracketWinner

func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName string) 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.

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 the auto-filled X-0 scoreline.

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 (e *Engine) RecordMatchResult(compId string, matchId string, result *state.MatchResult) error

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) StartCompetition

func (e *Engine) StartCompetition(id string) error

StartCompetition runs the competition-start pipeline: validate status, load participants/seeds, generate pools or bracket, commit the new Status atomically, save participants, generate schedule.

The final comp-status commit is wrapped in state.Store.UpdateCompetitionChanged so two concurrent StartCompetition calls can't both write the new Status (or, more importantly, one's "start" can't clobber the other's already-applied status change). The status re-check inside the transform aborts if another writer moved Status off Setup between our outer Load and the atomic commit.

Pipeline limitations (pre-existing, NOT addressed by this fix):

  • Pool/bracket generation (writes pools.csv / bracket.json) runs OUTSIDE the comp-config lock. Two concurrent starts could each generate and overwrite each other's pools.csv before the atomic Status commit serializes them. The later start's pools.csv wins; users see the second start's player ordering. A full fix would require holding the comp lock across the entire pipeline, which would conflict with the generator's internal use of the same lock for pools.csv / bracket.json writes. Left as a follow-up — needs a deeper refactor that either threads "lock already held" through the generators or restructures the lock granularity.
  • SaveParticipants + GenerateSchedule (steps after the comp commit) also have their own lock acquisitions. A failure mid-pipeline leaves partial state on disk. Pre-existing.

func (*Engine) StartMatch

func (e *Engine) StartMatch(compID, matchID string) error

StartMatch gates the scheduled → running transition by checking every participant's competitor-status. It returns *IneligibleCompetitorError (which matches errors.Is(err, ErrIneligibleCompetitor)) when any participant has Eligible: false; 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

func (e *Engine) StartMatchTx(tx state.StoreTx, compID, matchID string) error

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. 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:

  1. Wins (descending)
  2. Points scored (descending) — total ippons given across all completed Swiss matches the player participated in
  3. Head-to-head (descending): when (1) and (2) tie, the player who won the direct match between them ranks higher
  4. 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

func (e *Engine) UpdateMatchCourt(compId string, matchId string, newCourt string) error

func (*Engine) UpdateMatchTime

func (e *Engine) UpdateMatchTime(compId string, matchId string, scheduledAt string) error

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

type IneligibleCompetitorError struct {
	PlayerID string
	Reason   string
}

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.

data-model §5/§6.

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:

  1. perMatchMin = clockMin * multiplier (individual) or bouts * clockMin * multiplier + (bouts-1) * 1 (team — the +1 per switch covers rotation/transition between bouts).
  2. totalMin = perMatchMin * numMatches
  3. perCourt = totalMin / numCourts (clamped numCourts >= 1)
  4. perCourt *= (1 + buffer/100)
  5. total = round(perCourt) + ceremonyMinutes

FR-055, FR-057, FR-058, FR-059, data-model §5.

The per-match elapsed formula here is duplicated in scheduler_slots.go's perMatchElapsedMinutes, which performs the same calculation per court when assigning slots (T150 / T151). The two MUST agree to satisfy FR-059's 5%-parity requirement against the Excel Time Estimator — covered by the schedule tests.

type SwissRoundNotCompletedError

type SwissRoundNotCompletedError struct {
	CompID string
	Round  int
}

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

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

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL