Documentation
¶
Overview ¶
Package state, atomic_write.go provides atomic, durable file writes for all on-disk persistence in internal/state.
Constitution Principle VII requires that "tournament and match state MUST be persisted to disk before the server responds with success." A direct os.WriteFile call is neither atomic (a crash mid-write leaves a half-written file that won't parse on restart) nor durable (page-cache buffered writes return success but can be lost on power loss). This helper closes both gaps for every save in the package.
Algorithm: write to "<path>.tmp-<pid>-<nanos>" next to the target, fsync that file, close it, rename(tmp, target), atomic on POSIX when src and dst are on the same filesystem, then fsync the parent directory so the rename metadata is durable across power loss (a no-op on Windows where directory fsync isn't supported).
Why a unique-suffix .tmp filename? Per-comp mutex serializes writes to the same target in practice, but an explicit PID + nanosecond suffix is defensive: two processes pointing at the same data folder (e.g. a stuck-old-process / new-process restart overlap) would otherwise collide on a fixed ".tmp" sibling. The unique suffix also avoids ambiguity if the existing cache layer (getFileCache, pools.go:344) ever grew an mtime poll on directory listings, the .tmp file is invisible to the cache key because the cache is keyed by canonical filename, not by directory scan.
Package state, team_lineup.go owns the on-disk persistence for domain.TeamLineup (FR-040, Slice 7.B / T126).
One file per competition lives at tournament-data/competitions/<id>/lineups.yaml and is keyed by "<teamId>-<round>". A missing file is treated as "no lineups submitted yet". All load/mutate/save sequences run under the per-competition write lock (s.getCompLock) so concurrent PUTs to different teams in the same competition serialize correctly without clobbering each other's work, same pattern competitor_status.go uses.
Lineups are always editable, including while a match is running or completed. Scored bouts freeze fighter names in SubMatchResult.SideA/SideB at score-time; the lineup is only read to populate the next unscored bout.
Package state, transactions.go owns Store.WithTransaction, the per-competition-lock primitive that lets a handler perform several load + mutate + save operations against multiple files (config.md, pool-matches.csv, bracket.json, lineups.yaml, …) under a single acquire of the per-comp write lock. T155, NFR-010.
Why this exists. The pre-T155 handler pattern was to call Update*Changed / UpdatePoolMatchByID / UpdateBracket in sequence. each one acquires the per-comp lock, does its work, and releases. Concurrent writers can sneak in between the calls and clobber a half-committed cross-file mutation (e.g. score writes a pool match AND propagates a competitor-status update AND auto-completes the competition: three load+save pairs that should be serialised against each other as one operation, not three).
What "transaction" means here. Lock-level atomicity AND cross-file crash-atomicity via a write-ahead log (T210/T211/T212). Each save invoked through tx is STAGED into a per-transaction WAL instead of landing on disk; after fn returns nil, WithTransaction commits the WAL (atomic-rename of the intent file into <data>/.wal/), applies the staged writes to their target files, then deletes the WAL.
Failure modes:
- fn returns an error → no WAL on disk (Commit never ran); intents discarded; on-disk state unchanged.
- Crash after fn returns but before Commit → same as fn-error: intents discarded; on-disk state unchanged.
- Crash after Commit, before Apply completes → WAL on disk; Store.NewStore Scan replays on next start; targets land.
- Apply returns an error mid-way → WAL stays on disk for the next-start replay to finish; caller sees the error.
The contract callers MUST honour is "do your validation FIRST then stage writes via tx", once a tx method writes, that write enters the WAL and will land (either at Apply time or at replay time). A validation failure AFTER a write returns the error AND leaves the staged-but-uncommitted intent to be discarded; the on-disk state stays unchanged. There is still NO undo log: an already-committed WAL can't be rolled back after Apply has partially run; the only recovery is forward-completion via replay.
Read-your-own-writes within a tx IS supported via the WAL's in-memory intent map: tx-internal reads (tx.LoadCompetition, tx.LoadBracket, etc.) check the pending intents BEFORE going to disk, so a tx that saves pool-matches and then re-loads them sees the just-saved data, not the stale on-disk version (which won't update until Apply runs after fn returns). Without this, the existing TestWithTransaction_NestedCallDoesNotDeadlock contract (load-save-load round-trip) would silently see stale data.
Lock ordering. The per-comp lock is a sync.RWMutex; WithTransaction holds the WRITE lock for fn's entire duration. fn MUST call the load/save methods on the supplied StoreTx, those bypass re-locking. Calling any Store method directly from fn (e.g. s.LoadCompetition, s.SavePoolMatches) would deadlock because RWMutex is non-recursive. This mirrors the same advisory that already attaches to UpdateCompetitionChanged, UpdateBracket, UpdatePoolMatchByID.
Index ¶
- Constants
- Variables
- func ApplyCompetitionDefaults(c *Competition)
- func ApplyTournamentDefaults(t *Tournament)
- func DeriveQueuePositions(matches []MatchResult) []int
- func FindBestLineup(lineups map[string]domain.TeamLineup, teamID, matchID string, maxRound int) (domain.TeamLineup, bool)
- func FindBestLineupAny(lineups map[string]domain.TeamLineup, teamIDs []string, matchID string, ...) (domain.TeamLineup, bool)
- func HanteiPtr(b bool) *bool
- func IsDraw(decision string) bool
- func ValidateCompetitionID(id string) error
- func ValidateCompetitionTeamSize(kind string, teamSize int) error
- func ValidateSponsor(s Sponsor) error
- func ValidateTeamMatchType(t TeamMatchType, teamSize int) error
- func ValidateTheme(theme *Theme) error
- func ValidateTournamentMode(mode string) error
- type Announcement
- type AnnouncementStore
- func (s *AnnouncementStore) Add(msg string, dur time.Duration) (Announcement, []Announcement)
- func (s *AnnouncementStore) Clear() []Announcement
- func (s *AnnouncementStore) Get() *Announcement
- func (s *AnnouncementStore) List() []Announcement
- func (s *AnnouncementStore) Remove(id string) (bool, []Announcement)
- func (s *AnnouncementStore) Set(msg string, dur time.Duration) Announcement
- type Bracket
- type BracketMatch
- type BulkCheckInResult
- type Competition
- type CompetitionStatus
- type CourtOccupancy
- type EnchoMetadata
- type FightingSpiritAward
- type LoadParticipantsOpts
- type MatchResult
- type MatchStatus
- type Overrides
- type PlayerStanding
- type ScheduleEntry
- type Sponsor
- type Store
- func (s *Store) AddParticipant(compID string, p domain.Player, withZekkenName bool) (*domain.Player, error)
- func (s *Store) AnnouncementStore() *AnnouncementStore
- func (s *Store) BulkCheckIn(compID string, pids []string) (BulkCheckInResult, error)
- func (s *Store) DeleteCompetition(id string) error
- func (s *Store) DeleteCompetitionFile(id, filename string) error
- func (s *Store) DeleteTeamLineup(compID, teamID string, round int) error
- func (s *Store) DeleteTeamLineupForMatch(compID, teamID, matchID string) error
- func (s *Store) FileMtime(compID, filename string) int64
- func (s *Store) GetFolder() string
- func (s *Store) ListCompetitions() ([]string, error)
- func (s *Store) LoadBracket(compID string) (*Bracket, error)
- func (s *Store) LoadCompetition(id string) (*Competition, error)
- func (s *Store) LoadCompetitorStatus(compID string) (map[string]domain.CompetitorStatus, error)
- func (s *Store) LoadOverrides(compID string) (*Overrides, error)
- func (s *Store) LoadParticipants(compID string, withZekkenName bool) ([]domain.Player, error)
- func (s *Store) LoadParticipantsOpt(compID string, withZekkenName bool, opts LoadParticipantsOpts) ([]domain.Player, error)
- func (s *Store) LoadPoolMatches(compID string) ([]MatchResult, error)
- func (s *Store) LoadPoolMatchesLocked(compID string) ([]MatchResult, error)
- func (s *Store) LoadPools(compID string) ([]helper.Pool, error)
- func (s *Store) LoadSchedule(compID string) ([]ScheduleEntry, error)
- func (s *Store) LoadSeeds(compID string) ([]domain.SeedAssignment, error)
- func (s *Store) LoadTeamLineups(compID string) (map[string]domain.TeamLineup, error)
- func (s *Store) LoadTournament() (*Tournament, error)
- func (s *Store) ReplaceParticipant(compID string, pid string, withZekkenName bool, ...) (*domain.Player, error)
- func (s *Store) ResetOverrides(compID string) error
- func (s *Store) ResetOverridesChanged(compID string) (bool, error)
- func (s *Store) RunningMatchOnCourt(court, skipCompID string) (*CourtOccupancy, error)
- func (s *Store) SaveBracket(compID string, b *Bracket) error
- func (s *Store) SaveCompetition(c *Competition) error
- func (s *Store) SaveCompetitionChanged(c *Competition) (bool, error)
- func (s *Store) SaveOverrides(compID string, o *Overrides) error
- func (s *Store) SaveParticipants(compID string, players []domain.Player) error
- func (s *Store) SavePoolMatches(compID string, results []MatchResult) error
- func (s *Store) SavePools(compID string, pools []helper.Pool) error
- func (s *Store) SaveRankOverride(compID, poolID, playerName string, rank int) error
- func (s *Store) SaveRankOverrideChanged(compID, poolID, playerName string, rank int) (bool, error)
- func (s *Store) SaveSchedule(compID string, entries []ScheduleEntry) error
- func (s *Store) SaveScheduleChanged(compID string, entries []ScheduleEntry) (bool, error)
- func (s *Store) SaveSeeds(compID string, assignments []domain.SeedAssignment) error
- func (s *Store) SaveTournament(t *Tournament) error
- func (s *Store) SaveTournamentChanged(t *Tournament) (bool, error)
- func (s *Store) SaveWinnerOverride(compID, matchID, winnerName string) error
- func (s *Store) SetCompetitorStatus(compID string, status domain.CompetitorStatus) error
- func (s *Store) SetTeamLineup(compID string, lineup domain.TeamLineup, teamSize int) error
- func (s *Store) UpdateBracket(compID string, mutate func(*Bracket) error) error
- func (s *Store) UpdateCompetitionChanged(id string, transform func(current *Competition) (*Competition, error)) (bool, error)
- func (s *Store) UpdateParticipant(compID string, pid string, withZekkenName bool, ...) (*domain.Player, error)
- func (s *Store) UpdatePoolMatchByID(compID, matchID string, mutate func(*MatchResult)) (bool, error)
- func (s *Store) UpdateTournamentChanged(desired *Tournament, ...) (bool, error)
- func (s *Store) WithCompetitionRenameLock(fn func() error) error
- func (s *Store) WithCourtExclusivityLock(fn func() error) error
- func (s *Store) WithTransaction(compID string, fn func(tx StoreTx) error) error
- type StoreTx
- type SubMatchResult
- type TeamMatchType
- type TeamResultLine
- type Theme
- type Tournament
- type TournamentContact
Constants ¶
const ( MaxSponsorNameLen = 80 MaxSponsorLinkLen = 500 )
MaxSponsorNameLen and MaxSponsorLinkLen bound the metadata fields.
const ( TournamentModeOfficiated = "officiated" // default: main-gate on all admin routes TournamentModeSelfRun = "self-run" // main-gate skipped; elevated gate stays )
Tournament mode constants (mp-7h7).
const ( CompFormatPlayoffs = "playoffs" CompFormatMixed = "mixed" // FR-050 CompFormatLeague = "league" // FR-050 CompFormatSwiss = "swiss" // FR-050, FR-050a (US13) PoolFormatFull = "full" PoolFormatPartial = "partial" )
Competition.Format values.
const DaihyosenSubPosition = -1
DaihyosenSubPosition is the sentinel Position value marking a SubMatchResult as the daihyosen (representative bout) rather than a numbered roster bout (real bouts have a non-negative Position: fixed-format is 0-based, kachinuki 1-based). It is negative so it never collides with a real bout index. Use this constant instead of a bare -1.
const DecisionDraw = "hikiwake"
DecisionDraw is the canonical value for a tied (hikiwake) match.
const MaxSponsors = 6
MaxSponsors is the per-tournament sponsor count cap (mp-c38). Realistic count is 1–4; 6 leaves headroom without enabling abuse.
Variables ¶
var ( ErrSponsorNameRequired = errors.New("name is required (1–80 chars)") ErrSponsorNameTooLong = errors.New("name must be ≤80 chars") ErrSponsorLinkTooLong = errors.New("link must be ≤500 chars") ErrSponsorLinkInvalid = errors.New("link must be a valid http(s) URL") )
Sentinel errors returned by ValidateSponsor so handlers can map them to specific HTTP status codes without string-matching.
var ErrCompetitionNotInSetup = errors.New("competition has already started")
ErrCompetitionNotInSetup is returned by the setup-gated write paths (Store.AddParticipant and Store.ReplaceParticipant; both call requireSetupLocked) when the competition has already advanced past the setup phase. The status is re-checked under the per-competition lock so a concurrent POST /competitions/:id/start landing between the handler's outer check and the store call cannot leak a roster write into a started competition.
Store.UpdateParticipant is intentionally NOT gated by this error; check-in toggles must keep working while the competition is running. Future setup- only write paths should call requireSetupLocked under the same lock as AddParticipant/ReplaceParticipant do.
var ErrDuplicateName = errors.New("a participant with the same name and dojo already exists")
ErrDuplicateName is returned by AddParticipant, UpdateParticipant, and the bulk write path when the supplied (Player.Name, Player.Dojo) pair collides with another participant in the same roster (excluding the participant being edited, for the update path). The comparison is on the normalized (name, dojo) key; the SAME name at a DIFFERENT dojo is allowed (two real people at different clubs), so the message names both fields.
var ErrMismatchedTxCompID = errors.New("compID does not match transaction's competition")
ErrMismatchedTxCompID is returned by StoreTx methods when the supplied compID (or c.ID on SaveCompetition) does not match the competition the transaction was opened on. The transaction holds the per-comp lock for one ID only, so dispatching the locked helpers for any other ID would perform unlocked I/O.
var ErrParticipantNotFound = errors.New("participant not found")
ErrParticipantNotFound is returned by UpdateParticipant when the pid is not in the roster.
var ErrReservedName = errors.New("participant name collides with a reserved bracket-placeholder pattern")
ErrReservedName is returned by the participant write paths when a name matches an internal bracket-placeholder pattern (e.g. "Pool A-1st", "Winner of r1-m3"). Such names would be silently misclassified as unresolved bracket slots, making knockout matches permanently unscoreable.
Functions ¶
func ApplyCompetitionDefaults ¶
func ApplyCompetitionDefaults(c *Competition)
ApplyCompetitionDefaults fills zero-valued per-phase durations from the legacy MatchDuration field. Idempotent; safe to call repeatedly.
FR-054, NFR-025, R9: old config.md files predating per-phase durations carry only `match_duration`. We MUST preserve their schedule estimates.
func ApplyTournamentDefaults ¶
func ApplyTournamentDefaults(t *Tournament)
ApplyTournamentDefaults fills zero-valued schedule-estimator tuning fields on t with their canonical defaults: ClockToElapsedMultiplier=1.5, SlowestCourtBufferPct=10, and DurationDays=1. Idempotent; safe to call repeatedly. Also normalizes empty Mode to TournamentModeOfficiated so that tournament.md files predating mp-7h7 behave as officiated tournaments. FR-055, FR-057, R9.
func DeriveQueuePositions ¶
func DeriveQueuePositions(matches []MatchResult) []int
DeriveQueuePositions assigns a 1-indexed queue position to each scheduled match per court. Live (running) and completed matches receive 0.
Ordering: within each court, positions are assigned in (status priority, scheduledAt, original index) order, the same basis used by ScheduleViewer (viewer.jsx) and the client-side SSE recompute (_orderByCourtKey in patch.jsx), so "Next up / N before yours" labels are consistent between server responses and the post-SSE client view.
FR-025, R3: positions are recomputed at serve time and on every SSE match-state change so viewers see the queue shrink as matches finish. The function is pure and side-effect-free; the caller is responsible for assigning the returned positions onto the MatchResult slice.
func FindBestLineup ¶ added in v1.0.0
func FindBestLineup(lineups map[string]domain.TeamLineup, teamID, matchID string, maxRound int) (domain.TeamLineup, bool)
FindBestLineup returns the most relevant lineup for teamID from a pre-loaded lineups map, following the AMENDMENT 1 priority order:
- Match-scoped entry keyed by matchID (exact match for the specific bout).
- Round-scoped: the highest round <= maxRound (the current match's round, e.g. 0 for pool matches, bracket round index for knockout matches).
- Round-scoped: the highest round overall (fallback when no saved lineup has round <= maxRound, e.g. operator saved a bracket-phase lineup but the current match is a pool match, or vice versa).
Returns the lineup and true when found, the zero value and false otherwise. Callers should use LoadTeamLineups to obtain the map before calling this.
func FindBestLineupAny ¶ added in v1.0.0
func FindBestLineupAny(lineups map[string]domain.TeamLineup, teamIDs []string, matchID string, maxRound int) (domain.TeamLineup, bool)
FindBestLineupAny is FindBestLineup for a set of candidate team keys. The lineup editor keys lineups by the team PARTICIPANT ID (player.id) while match sides carry the team display NAME, so callers resolving a lineup for a match side must try both keys ("match on id OR name"). The AMENDMENT 1 priority tiers apply ACROSS the whole key set: a match-scoped entry under any key beats a round-scoped entry under any key. Within a tier, ties between keys resolve to the first teamID in the slice that has an entry.
func HanteiPtr ¶ added in v0.17.0
HanteiPtr returns &b when b is true, nil otherwise. Use on READ paths that project a BracketMatch.DecidedByHantei (bool) into a MatchResult (which uses *bool with omitempty) so the wire payload OMITS the field for non-hantei matches rather than emitting an explicit "false". Always assigning &bm.DecidedByHantei would leak a non-nil pointer for every non-hantei match, defeating the omitempty contract.
func ValidateCompetitionID ¶
func ValidateCompetitionTeamSize ¶ added in v0.17.0
ValidateCompetitionTeamSize rejects invalid teamSize values. Engine code uses both TeamSize > 0 and Kind == "team" in different paths; an inconsistent pair (e.g. kind="individual" with teamSize=3) causes misclassification:
- negative values are always invalid
- teamSize == 1 is always invalid (would flag as team via TeamSize > 0)
- non-team kinds require teamSize == 0
- kind == "team" requires teamSize >= 2
func ValidateSponsor ¶ added in v0.17.0
ValidateSponsor checks name length and link format. Centralises the rules so handlers, tests, and future import paths agree. Name/link must already be trimmed by the caller.
func ValidateTeamMatchType ¶
func ValidateTeamMatchType(t TeamMatchType, teamSize int) error
ValidateTeamMatchType returns nil when the value is acceptable on the given Competition (empty == fixed default, kachinuki requires TeamSize >= 2). FR-044.
func ValidateTheme ¶ added in v0.17.0
ValidateTheme returns an error when any non-empty color field is not a valid 6-digit CSS hex value, or when WindowTitle exceeds 100 characters. An entirely nil/empty Theme is always valid.
func ValidateTournamentMode ¶ added in v0.17.0
ValidateTournamentMode reports whether the given mode value is acceptable. Empty string is treated as "officiated" (backward compatibility). Only the two defined constants are accepted; any other value returns an error.
Types ¶
type Announcement ¶
type AnnouncementStore ¶
type AnnouncementStore struct {
// contains filtered or unexported fields
}
func NewAnnouncementStore ¶
func NewAnnouncementStore() *AnnouncementStore
func (*AnnouncementStore) Add ¶ added in v0.17.0
func (s *AnnouncementStore) Add(msg string, dur time.Duration) (Announcement, []Announcement)
Add appends a new announcement. Returns the new item and the updated list snapshot under the same lock, eliminating any race between mutation and broadcast.
func (*AnnouncementStore) Clear ¶
func (s *AnnouncementStore) Clear() []Announcement
Clear removes all announcements and returns an empty snapshot.
func (*AnnouncementStore) Get ¶
func (s *AnnouncementStore) Get() *Announcement
Get returns the most recent active announcement, or nil. Kept for backward-compat with callers expecting the single-slot API.
func (*AnnouncementStore) List ¶ added in v0.17.0
func (s *AnnouncementStore) List() []Announcement
List returns a copy of all currently active (non-expired) announcements, oldest first.
func (*AnnouncementStore) Remove ¶ added in v0.17.0
func (s *AnnouncementStore) Remove(id string) (bool, []Announcement)
Remove dismisses the announcement with the given ID. Returns whether it was found and the updated list snapshot under the same lock.
func (*AnnouncementStore) Set ¶
func (s *AnnouncementStore) Set(msg string, dur time.Duration) Announcement
Set adds a single announcement and clears all prior ones. Kept for backward-compat; new callers should prefer Add.
type Bracket ¶
type Bracket struct {
Rounds [][]BracketMatch `json:"rounds"`
// Preview marks a bracket whose leaves are pool-origin PLACEHOLDERS
// (e.g. "Pool A 1st") rather than resolved players. It is generated on a
// mixed (Pools + Knockout) competition at draw time so the operator can
// see the elimination structure that the pools feed, mirroring the Excel
// Tree sheet. A preview bracket is read-only: the actual knockout is
// played in the separate playoffs competition created from this source.
Preview bool `json:"preview,omitempty"`
// ThirdPlaceMatch is the optional 3rd-place (bronze) playoff match. It is a
// SIBLING field rather than a row in Rounds: Rounds is power-of-two,
// position-encoded (propagateBracketWinner advances winner i into
// Rounds[rIdx+1][i/2]), so splicing a bronze match into that geometry would
// corrupt advancement math. The pointer + omitempty keeps bracket.json
// backward-compatible: nil for every existing/kendo bracket. Generated only
// for naginata competitions with a real semifinal round (len(Rounds) >= 2);
// its ID is always "m-bronze", and its sides are filled from the two
// semifinal losers by propagateBracketWinner.
ThirdPlaceMatch *BracketMatch `json:"thirdPlaceMatch,omitempty"`
}
type BracketMatch ¶
type BracketMatch struct {
ID string `json:"id"`
SideA string `json:"sideA"`
SideB string `json:"sideB"`
Winner string `json:"winner"`
Status MatchStatus `json:"status"`
Court string `json:"court"`
ScheduledAt string `json:"scheduledAt"`
// Additional fields from design
ScoreA string `json:"scoreA"`
ScoreB string `json:"scoreB"`
IsOverridden bool `json:"isOverridden"`
QueuePosition int `json:"queuePosition,omitempty"`
// MatchNumber is the sequential bracket match number, matching the
// "Match N" label printed on the Excel tree sheet. 0 means unset; for a
// BracketMatch that is a hidden/bye placeholder, or a legacy bracket saved
// before numbering was assigned.
MatchNumber int `json:"matchNumber,omitempty"`
// Decision-type metadata mirrors MatchResult so an elimination-stage
// kiken/fusenpai/encho is reconstructable from bracket.json alone
// (label rendering, Excel export, SSE replays).
Decision string `json:"decision,omitempty"`
DecisionBy string `json:"decisionBy,omitempty"`
DecisionReason string `json:"decisionReason,omitempty"`
Encho *EnchoMetadata `json:"encho,omitempty"`
// DecidedByHantei mirrors MatchResult.DecidedByHantei for bracket reads.
// YAML tag included for parity with MatchResult and future YAML-serialised contexts.
DecidedByHantei bool `json:"decidedByHantei,omitempty" yaml:"decided_by_hantei,omitempty"`
// SubResults persists per-bout results for team bracket matches so the
// score editor can restore hantei state and bout-level detail on re-open.
SubResults []SubMatchResult `json:"subResults,omitempty"`
// ResultSource mirrors MatchResult.ResultSource for bracket matches.
ResultSource string `json:"resultSource,omitempty"`
// CorrectionReason mirrors MatchResult.CorrectionReason for bracket
// matches, persisted in bracket.json for audit. Set when an operator
// overwrites a completed result (correction), omitted on first completion.
CorrectionReason string `json:"correctionReason,omitempty"`
// FlagsA / FlagsB mirror MatchResult.FlagsA/FlagsB for engi (kata
// demonstration) bracket bouts, persisted in bracket.json so the flag
// counts survive a restart. Zero for non-engi matches.
FlagsA int `json:"flagsA,omitempty"`
FlagsB int `json:"flagsB,omitempty"`
// Display metadata (mp-7f2w); additive, computed at generation time so the
// viewer can render the bracket with the SAME effective-round columns as the
// printed Excel Tree sheet (matches grouped by depth-from-root, structural
// byes skipping a column) instead of the balanced pow2 rounds. These fields
// are purely for rendering; the resolution/scoring/scheduling logic continues
// to use the positional ID + "Winner of rX-mY" scheme unchanged.
//
// DisplayRound is the effective round counted from the final (1 = Final,
// 2 = Semifinal, …). 0 means unset; either a legacy bracket generated before
// this field existed (viewer falls back to positional rendering) or a Hidden
// phantom match. Hidden marks a structural-bye match (empty-vs-empty dead
// match, or a latent bye where one side never had an opponent) that is not a
// real bout and must not be drawn as a match card. Feeders holds the IDs of
// the two real feeder matches whose winners meet here, in [A, B] order; an
// empty string means that side is a seeded entrant / bye (no connector line).
DisplayRound int `json:"displayRound,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Feeders []string `json:"feeders,omitempty"`
// ModifiedAt mirrors MatchResult.ModifiedAt (mp-y3nk): the server-relative
// unix-millis timestamp of the last change to this bracket match's result,
// persisted in bracket.json so timestamp reconciliation survives a restart.
// 0 = unstamped/legacy: arrival-order, still applies (never dropped). See
// domain.ApplyByTimestamp.
ModifiedAt int64 `json:"modifiedAt,omitempty"`
}
func (BracketMatch) MarshalJSON ¶ added in v1.0.0
func (m BracketMatch) MarshalJSON() ([]byte, error)
MarshalJSON mirrors MatchResult.MarshalJSON for bracket (elimination) matches: without it, knockout team matches reached the frontend with no teamResult and every score surface fell back to the legacy IV-only string while pool matches showed IV and PW (bead mp-8b1b). Unlike MatchResult, BracketMatch also persists to bracket.json through this same marshal, so the derived teamResult lands on disk too; that is deliberate (one choke point, no wire-vs-disk type to drift) and safe: the struct has no TeamResult field, so loading ignores it and every save recomputes it from SubResults.
func (*BracketMatch) TeamResult ¶ added in v1.0.0
func (m *BracketMatch) TeamResult() *TeamResultLine
TeamResult returns the team-match summary for this bracket match, or nil for an individual match. See TeamResultFrom.
type BulkCheckInResult ¶ added in v0.17.0
type BulkCheckInResult struct {
CheckedIn int `json:"checkedIn"`
AlreadyCheckedIn int `json:"alreadyCheckedIn"`
NotFound []string `json:"notFound"`
}
BulkCheckInResult carries the outcome of a BulkCheckIn call.
type Competition ¶
type Competition struct {
ID string `yaml:"id" json:"id"`
Name string `yaml:"name" json:"name"`
Kind string `yaml:"kind" json:"kind"`
Format string `yaml:"format" json:"format"`
PoolFormat string `yaml:"pool_format,omitempty" json:"poolFormat,omitempty"` // "full" (default) | "partial"
TeamSize int `yaml:"team_size" json:"teamSize"`
PoolSize int `yaml:"pool_size" json:"poolSize"`
PoolSizeMode string `yaml:"pool_size_mode" json:"poolSizeMode"`
PoolWinners int `yaml:"pool_winners" json:"poolWinners"`
RoundRobin bool `yaml:"round_robin" json:"roundRobin"`
Courts []string `yaml:"courts" json:"courts"`
StartTime string `yaml:"start_time" json:"startTime"`
Date string `yaml:"date" json:"date"`
Status CompetitionStatus `yaml:"status" json:"status"`
Mirror bool `yaml:"mirror" json:"mirror"`
WithZekkenName bool `yaml:"with_zekken_name" json:"withZekkenName"`
NumberPrefix string `yaml:"number_prefix,omitempty" json:"numberPrefix,omitempty"`
HasParticipantIDs bool `yaml:"has_participant_ids,omitempty" json:"hasParticipantIDs,omitempty"`
PoolMatchDuration int `yaml:"pool_match_duration,omitempty" json:"poolMatchDuration,omitempty"`
PlayoffMatchDuration int `yaml:"playoff_match_duration,omitempty" json:"playoffMatchDuration,omitempty"`
// MaxEnchoPeriods caps how many encho (overtime) periods one match
// may run before the operator must call daihyosen. Zero means
// unlimited (FIK general default). T104, CHK029.
MaxEnchoPeriods int `yaml:"max_encho_periods,omitempty" json:"maxEnchoPeriods,omitempty"`
// TeamMatchType selects the team-match format (FR-044). Empty value
// is treated as TeamMatchTypeFixed for backward compatibility; all
// N×1 bouts are pre-scheduled by position. TeamMatchTypeKachinuki
// schedules only the first bout; subsequent bouts are derived
// dynamically from prior bout outcomes ("winner stays on"). See
// engine/kachinuki.go for the advancement semantics. Ignored when
// TeamSize is 0 (individual competitions).
TeamMatchType TeamMatchType `yaml:"team_match_type,omitempty" json:"teamMatchType,omitempty"`
// Legacy single-phase duration. Captured at unmarshal time and used by
// ApplyCompetitionDefaults to populate the per-phase fields above when
// they are zero. Not persisted on save; only here so older YAML files
// round-trip through the new schema.
MatchDuration int `yaml:"match_duration,omitempty" json:"matchDuration,omitempty"`
// SwissRounds is the number of rounds played in a Swiss-format
// competition (FR-050a). Ignored when Format != CompFormatSwiss.
// Persisted so resuming a Swiss tournament reads the same round
// budget on subsequent loads.
SwissRounds int `yaml:"swiss_rounds,omitempty" json:"swissRounds,omitempty"`
// SwissCurrentRound tracks which round has been generated so far
// (FR-050d). 0 = not started; the value increments after each
// successful GenerateSwissRound. Used by the "Generate next round"
// gate to refuse re-generation of an in-progress round.
SwissCurrentRound int `yaml:"swiss_current_round,omitempty" json:"swissCurrentRound,omitempty"`
// Naginata selects the Naginata ippon set for this competition.
// When true, the score editor offers an extra "S" (Sune) button
// in addition to the standard M/K/D/T/H set. Default false = Kendo.
Naginata bool `yaml:"naginata,omitempty" json:"naginata"`
// Engi selects flag-scoring (Engi-kyogi / kata competition) mode for this
// competition. When true, bouts are decided by referee flag counts
// (FlagsA/FlagsB) instead of ippon waza letters, and standings rank by
// wins then accumulated own-side flags. Independent of the Naginata flag;
// engi has no ippons or Sune. Supports all formats (pools, league, knockout,
// mixed). Locked after draw like Naginata. Default false = ippon-scored.
//
// Engi competitors are PAIRS (two member names, one shared dojo). On the Go
// side a pair is a single competitor whose Player.Name holds BOTH member
// names combined as "Name 1 - Name 2"; Player.Dojo holds the shared dojo.
// Engi does not alter the participant CSV layout: when WithZekkenName is
// set, Player.DisplayName carries the combined pair zekken
// ("ZEKKEN1 - ZEKKEN2") exactly like any other competition.
Engi bool `yaml:"engi,omitempty" json:"engi"`
CheckInEnabled bool `yaml:"check_in_enabled,omitempty" json:"checkInEnabled,omitempty"`
FightingSpiritAwards []FightingSpiritAward `yaml:"fighting_spirit_awards,omitempty" json:"fightingSpiritAwards,omitempty"`
// LeagueTiebreakTopN is the highest finishing position (rank) whose ties must
// be broken before league play can finish, so a league earns its top
// placements like a bracket instead of completing with an unearned tie. It is
// operator-settable (3 or 4) only for TEAM leagues; INDIVIDUAL leagues are not
// given the control and use the default band (3) via effectiveTopN. Team
// leagues break ties with a daihyosen (LeagueTiebreakCandidates); individual
// leagues break them with an ippon-shobu bout (tieNeedsIndividualBreak ->
// InjectTiebreakerMatches). Allowed explicit values are 3 (default) and 4.
//
// A "consequential" tied group is one whose position range intersects the
// band [1..LeagueTiebreakTopN]; i.e. its best (lowest-numbered) position is
// ≤ TopN. For example, with TopN=3 a tie involving teams at positions 1–2 is
// consequential (it affects who wins or is runner-up), but a tie at positions
// 4–5 is not. When the best-placed team in a tied group finishes WORSE than
// position TopN (its top position is strictly greater than TopN, so the whole
// group sits below the band), the group is non-consequential and no
// tie-breaker is needed.
//
// The kendo convention of two joint 3rd places interacts here: when
// LeagueTwoThirdPlaces is true and TopN is 3 or 4, a tied group that sits
// ENTIRELY at position 3 or below does not need a 3rd-vs-4th decider;
// both teams are awarded 3rd place and the group is non-consequential.
// Zero is treated as the default (3) for a team league (Format == "league"
// && Kind == "team") at draw time.
LeagueTiebreakTopN int `yaml:"league_tiebreak_top_n,omitempty" json:"leagueTiebreakTopN,omitempty"`
// LeagueTwoThirdPlaces controls whether two (or more) joint 3rd places are
// awarded when competitors tie at the 3rd-position boundary. Applies to ALL
// leagues (team AND individual); the standard kendo convention enables it,
// naginata leaves it off (single 3rd).
//
// Two effects:
// - Standings rank (all leagues): a genuine tied group whose best position
// is 3rd or lower is given a SHARED rank (e.g. 3,3) rather than arbitrary
// sequential ranks, so the standings table and the closing-ceremony podium
// show joint 3rd places instead of relabeling the 4th finisher. See
// CalculatePoolStandings (engine/scoring.go).
// - Team-league tie-breaker gating: a tied group whose ENTIRE position range
// falls at position ≥ 3 is treated as non-consequential (both receive 3rd
// place, no bronze decider). See isConsequentialTie (engine).
//
// When false (default), all ties within [1..LeagueTiebreakTopN] are
// consequential and may require a tie-breaker, and the podium shows a single
// 3rd place.
LeagueTwoThirdPlaces bool `yaml:"league_two_third_places,omitempty" json:"leagueTwoThirdPlaces,omitempty"`
// LeagueTiebreakFinalized is set to true by the operator via
// POST /api/competitions/:id/league-tiebreak/finalize to accept the
// current standings as final without running a tie-breaker. When true,
// LeagueTiebreakCandidates returns an empty slice (even if consequential
// ties remain), which allows MaybeAutoCompletePools to transition the
// competition to CompStatusComplete. Phase 3b.
LeagueTiebreakFinalized bool `yaml:"league_tiebreak_finalized,omitempty" json:"leagueTiebreakFinalized,omitempty"`
Players []domain.Player `yaml:"-" json:"players"`
}
func (Competition) EffectivePoolWinners ¶ added in v0.17.0
func (c Competition) EffectivePoolWinners() int
EffectivePoolWinners returns the number of finishers each pool promotes to the knockout, defaulting to 2 when unset (<=0). Single source of truth for the qualifier count so the draw-time validation (pools.go), preview-bracket build (bracket.go), incremental seeding (knockout.go), and schedule estimation (estimate_schedule.go) cannot drift from one another.
func (*Competition) EffectiveWithZekkenName ¶ added in v1.0.0
func (c *Competition) EffectiveWithZekkenName() bool
EffectiveWithZekkenName reports whether the participant CSV reader/writer must use the 4-column [id, Name, DisplayName, Dojo] layout. It is currently identical to WithZekkenName (engi pairs store both member names combined in the Name field, so they no longer force the zekken layout); kept as a method so any future layout modifier has a single seam.
func (Competition) IsPlayoffEnabled ¶
func (c Competition) IsPlayoffEnabled() bool
IsPlayoffEnabled reports whether this competition runs a knockout/playoff phase. League and pure-pools formats do not; mixed and playoffs do.
FR-050, FR-051: when Format == "league", the UI must hide playoff-bracket affordances and present pool standings as final.
type CompetitionStatus ¶
type CompetitionStatus string
const ( CompStatusSetup CompetitionStatus = "setup" CompStatusDrawReady CompetitionStatus = "draw-ready" CompStatusPools CompetitionStatus = "pools" CompStatusPlayoffs CompetitionStatus = "playoffs" CompStatusComplete CompetitionStatus = "completed" CompStatusInvalid CompetitionStatus = "invalid" )
type CourtOccupancy ¶ added in v0.17.0
CourtOccupancy carries the first running match found on a given court.
type EnchoMetadata ¶
type EnchoMetadata struct {
PeriodCount int `json:"periodCount" yaml:"periodCount"`
}
EnchoMetadata records overtime / sudden-death periods played in a match. Read/persisted only in Slice 1; the score endpoint accepts it but does not yet act on it. Slice 3 (T076) will wire it into the decision logic.
FR-032
func (*EnchoMetadata) Clone ¶ added in v0.17.0
func (e *EnchoMetadata) Clone() *EnchoMetadata
Clone returns a deep copy of the encho metadata, or nil if e is nil. Used by the match/bracket copy paths so cached state never shares an Encho pointer with a returned value.
type FightingSpiritAward ¶ added in v0.17.0
type FightingSpiritAward struct {
Title string `yaml:"title" json:"title"`
RecipientName string `yaml:"recipient_name" json:"recipientName"`
RecipientDojo string `yaml:"recipient_dojo,omitempty" json:"recipientDojo,omitempty"`
}
FightingSpiritAward is an optional, per-competition individual honour (敢闘賞 / kantōshō) independent of the placement podium. Recipient is a plain name string for parity with the rest of the system (no UUID ref); it is display-only and matches nothing.
type LoadParticipantsOpts ¶
type LoadParticipantsOpts struct {
WithSeeds bool // set false to skip the seeds.csv read (hot list paths)
HasIDs *bool // nil = auto-detect from first line; non-nil uses cached Competition.HasParticipantIDs
}
LoadParticipantsOpts controls optional behavior in LoadParticipants.
type MatchResult ¶
type MatchResult struct {
ID string `json:"id"`
SideA string `json:"sideA"` // Player/Team Name
SideB string `json:"sideB"`
Winner string `json:"winner"`
// SideAID/SideBID/WinnerID carry the participant UUID for each side and
// the winner when available. Sides are stored by name everywhere else,
// but a name is not unique within a competition; two participants from
// different dojos may share a name (CheckDuplicateEntriesByNameDojo only
// rejects same-name AND same-dojo). These ids let consumers (e.g. the
// league matrix) cross-reference a match cell to the right row/column
// player AND tell apart the winner when two identical-name players meet.
// Purely additive metadata: all Go scoring/standings logic still keys on
// name, and these stay empty for legacy data, so behavior is unchanged
// when ids are absent. omitempty + append-only CSV columns keep old
// files/readers fully compatible.
SideAID string `json:"sideAId,omitempty"`
SideBID string `json:"sideBId,omitempty"`
WinnerID string `json:"winnerId,omitempty"`
// WinnerSide is a transient hint ("A"/"B") set by scoring handlers that
// know the winning SIDE unambiguously (e.g. quick-score, where the
// winner is decided by ippon counts, not a name). writeMatchResult uses
// it to resolve WinnerID from the stored side ids even when both sides
// share a name. Never persisted (json/CSV omit); it only carries the
// side decision from the handler to the id-resolution step.
WinnerSide string `json:"-" yaml:"-"`
IpponsA []string `json:"ipponsA"` // waza letters M/K/D/T/H/S (naginata), or ○ (FIK default-win marker)
IpponsB []string `json:"ipponsB"`
HansokuA int `json:"hansokuA"`
HansokuB int `json:"hansokuB"`
Decision string `json:"decision"`
DecisionBy string `json:"decisionBy,omitempty"`
DecisionReason string `json:"decisionReason,omitempty"`
Status MatchStatus `json:"status"`
Court string `json:"court"`
Round int `json:"round" yaml:"round"`
ScheduledAt string `json:"scheduledAt"`
SubResults []SubMatchResult `json:"subResults,omitempty"`
Encho *EnchoMetadata `json:"encho,omitempty" yaml:"encho,omitempty"`
QueuePosition int `json:"queuePosition,omitempty" yaml:"-"`
// DecidedByHantei is true when the winner was declared by referee
// hantei on a tied bout (FIK Article 7-5 / 29-6). Overtime is not a
// precondition: a tied scoreline may be taken straight to a judges'
// decision. Distinguishes a judges' decision from an ippon-derived win
// for stats, audit, and display. Zero value omitted from the wire.
//
// Pointer semantics at the API boundary: when a client omits the
// field (nil) on a BRACKET-match score request, the engine preserves
// whatever value is already stored; when the client explicitly sends
// true or false the engine applies it. This prevents a re-score that
// doesn't mention the flag from silently clearing a previously-
// recorded hantei decision.
//
// Preserve-on-nil applies ONLY to bracket matches (see
// engine/scoring.go:recordBracketMatchResult and
// engine/scoring_tx.go's bracket commit branch; both gate the
// assignment on result.DecidedByHantei != nil). Pool matches are
// merged with `*r = *result`, so a nil pointer there will clear any
// stored value. This is acceptable in practice because FIK rules
// don't permit hantei in pool play (see persistence caveat below).
//
// On READ paths that project BracketMatch.DecidedByHantei (bool) back
// into MatchResult for SSE / HTTP responses, use HanteiPtr below so
// non-hantei matches emit nil (omitempty), keeping the wire payload
// minimal and signalling "no hantei" by absence rather than an
// explicit false.
//
// Persistence caveat: pool matches are stored in pool-matches.csv,
// whose column layout does NOT include this field; so a hantei
// decision on a pool match survives in-memory and on the SSE wire,
// but does NOT survive a server restart. Bracket matches are stored
// in bracket.json, which serializes the full struct, so the flag
// survives there. See BracketMatch.DecidedByHantei for the mirror;
// pool-level hantei is a rare-enough case (FIK doesn't normally
// allow it in pool play) that the gap is acceptable. The yaml tag
// is retained for future YAML-serialised contexts.
DecidedByHantei *bool `json:"decidedByHantei,omitempty" yaml:"decided_by_hantei,omitempty"`
// ResultSource records how the result was submitted: "admin" (operator with
// password), "self-reported" (participant in self-run mode), or "" (legacy/
// unset). Set by the score handler; omitted from wire when empty.
ResultSource string `json:"resultSource,omitempty" yaml:"result_source,omitempty"`
// CorrectionReason is a mandatory audit justification when an operator
// overwrites a completed match result. Format: "<category>: <note>" (e.g.
// "Scoring error: wrong waza entered"). Only required on corrections
// (completed → completed); omitted on first completions. Append-only CSV
// column (rec index 19, the 20th column, after the original 19) and the
// bracket.json field keep older files fully compatible.
CorrectionReason string `json:"correctionReason,omitempty" yaml:"correction_reason,omitempty"`
// RepPlayerA / RepPlayerB name the individual competitor each TEAM fields
// for a pool/league daihyosen or tiebreaker rep bout ("Pool X-DH-N" /
// "Pool X-TB-N"). For those bouts SideA/SideB hold the TEAM names (the
// standings entries are teams), so the actual fighters can't be recovered
// from the sides alone. The operator records them in the rep-bout scorer
// (picked from each team's roster) and the per-court display shows the rep
// player above the team name. Empty for every non-supplementary match.
// Append-only CSV columns (rec index 20/21, after CorrectionReason at 19)
// keep older pool-matches.csv files fully compatible. (mp-62vr)
RepPlayerA string `json:"repPlayerA,omitempty" yaml:"rep_player_a,omitempty"`
RepPlayerB string `json:"repPlayerB,omitempty" yaml:"rep_player_b,omitempty"`
// FlagsA / FlagsB are the referee flag counts per side for engi (kata
// demonstration) bouts. Storage only; kendo scoring never reads or writes
// them. For pool matches they survive a restart via append-only trailing CSV
// columns (rec index 22/23, after RepPlayerB at 20/21); older files with the
// columns absent load as 0. Bracket matches persist fine via bracket.json.
FlagsA int `json:"flagsA,omitempty" yaml:"flags_a,omitempty"`
FlagsB int `json:"flagsB,omitempty" yaml:"flags_b,omitempty"`
// Rev is a client-monotonic revision counter carried on "running"-status
// autosave writes. The server uses it (scoped to RevSession) to drop stale
// in-flight writes that arrive out of order after a reconnect flush
// (C2 rev-guard). It is wire-only: never serialized to CSV (no column) nor
// to bracket.json (which uses BracketMatch), and yaml:"-" keeps it out of
// config persistence. A plain value copy of MatchResult carries it in
// memory, which is harmless. Defaulting to 0 (omitempty) means a payload
// without the field is treated as unversioned and always proceeds.
Rev int64 `json:"rev,omitempty" yaml:"-"`
// RevSession identifies the client scoring session (one per page load,
// random). The rev-guard compares Rev ONLY within the same RevSession; a
// write from a new session (page reload / different device) always takes
// over rather than being dropped as stale against a high-water mark left
// by a prior session. Same wire-only persistence guarantees as Rev.
RevSession string `json:"revSession,omitempty" yaml:"-"`
// ModifiedAt is the SERVER-RELATIVE unix-millis timestamp of the last change
// to this match's result-bearing fields (mp-y3nk timestamp reconciliation).
// Clients stamp each score/override write with server-relative time (learned
// via GET /api/time). Timestamp last-write-wins currently applies to the
// BRACKET write path only: for a bracket match the handler drops a write
// whose ModifiedAt is older than the stored value (so a reconnecting offline
// court's stale change never overwrites a newer one), and it is persisted in
// bracket.json (see BracketMatch.ModifiedAt) so the comparison survives a
// restart. For POOL matches this field is wire-only: it is NOT written to
// pool-matches.csv and NOT yet used for reconciliation (a scoped follow-up),
// so it resets to 0 on restart and pool writes keep arrival-order behavior.
// The completed-never-reverted guard stays on top regardless. 0
// (absent/legacy) means "unstamped": it is treated as arrival-order and still
// APPLIES (it does NOT lose to a stamped write), so old files and un-stamped
// clients behave exactly as before rather than having a legitimate change
// silently dropped. See domain.ApplyByTimestamp.
ModifiedAt int64 `json:"modifiedAt,omitempty" yaml:"-"`
}
func (MatchResult) MarshalJSON ¶ added in v1.0.0
func (m MatchResult) MarshalJSON() ([]byte, error)
MarshalJSON augments the wire form of a MatchResult with the computed teamResult (IV/PW) for team matches. The alias type sheds MarshalJSON to avoid infinite recursion while preserving every field tag, so the payload is byte-identical apart from the added, omitempty teamResult object. This is the single serialization choke point for every read path (pool-matches, bracket, schedule, SSE), so the frontend never re-derives PW. MatchResult is wire-only JSON (pool matches persist to CSV, bracket matches to bracket.json via BracketMatch), so this does not affect on-disk state.
func (*MatchResult) TeamResult ¶ added in v1.0.0
func (m *MatchResult) TeamResult() *TeamResultLine
TeamResult returns the team-match summary for this match, or nil for an individual match. See TeamResultFrom.
type MatchStatus ¶
type MatchStatus string
const ( MatchStatusScheduled MatchStatus = "scheduled" MatchStatusRunning MatchStatus = "running" MatchStatusCompleted MatchStatus = "completed" )
type PlayerStanding ¶
type PlayerStanding struct {
Player domain.Player `json:"player"`
Wins int `json:"wins"`
Losses int `json:"losses"`
Draws int `json:"draws"`
IpponsGiven int `json:"ipponsGiven"`
IpponsTaken int `json:"ipponsTaken"`
Points int `json:"points"`
ScoreSummary string `json:"scoreSummary,omitempty"`
Rank int `json:"rank"`
IsOverridden bool `json:"isOverridden"`
IndividualWins int `json:"individualWins,omitempty"`
IndividualLosses int `json:"individualLosses,omitempty"`
IndividualDraws int `json:"individualDraws,omitempty"`
PointsWon int `json:"pointsWon,omitempty"`
PointsLost int `json:"pointsLost,omitempty"`
Tied bool `json:"tied,omitempty"`
// Flags is the total accumulated own-side referee flags for engi (kata
// demonstration) standings. A dedicated field, NOT an overload of
// IpponsGiven, so the wire stays self-describing (wins vs flags as distinct
// columns). Zero for non-engi competitions (omitempty drops it).
Flags int `json:"flags,omitempty"`
}
type ScheduleEntry ¶
type ScheduleEntry struct {
MatchType string `json:"matchType"` // pool | bracket | break
MatchRef string `json:"matchRef"` // ID of the match (empty for breaks)
Court string `json:"court"`
Date string `json:"date"` // DD-MM-YYYY (matches Tournament.Date / Competition.Date canonical), reserved for future multi-day tournament use
ScheduledAt string `json:"scheduledAt"` // HH:MM
Status string `json:"status"`
IsBreak bool `json:"isBreak,omitempty"`
Label string `json:"label,omitempty"` // display label for breaks
}
type Sponsor ¶ added in v0.17.0
type Sponsor struct {
Name string `yaml:"name" json:"name"`
File string `yaml:"file" json:"file"`
Link string `yaml:"link,omitempty" json:"link,omitempty"`
}
Sponsor is a single sponsor logo entry. File is the server-generated random filename under tournament-data/sponsors/; Name is the alt text; Link is optional and, when set, makes the logo clickable on the viewer surface only (display surfaces never render anchors). See mp-c38.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
func (*Store) AddParticipant ¶ added in v0.17.0
func (s *Store) AddParticipant(compID string, p domain.Player, withZekkenName bool) (*domain.Player, error)
AddParticipant atomically loads the participant list, mints a UUIDv4 ID, sets PoolPosition to the new tail index, appends the participant, and saves the file. Matches the ID format used everywhere else (see newParticipantID and saveParticipantsNoLock's ID-fill branch) so the format-sniffer in loadParticipantsNoLock keeps a single contract.
Re-checks the competition status under the per-comp lock; a concurrent POST /competitions/:id/start landing between the handler's outer check and this lock would otherwise leak a roster mutation into a started competition.
func (*Store) AnnouncementStore ¶
func (s *Store) AnnouncementStore() *AnnouncementStore
func (*Store) BulkCheckIn ¶ added in v0.17.0
func (s *Store) BulkCheckIn(compID string, pids []string) (BulkCheckInResult, error)
BulkCheckIn atomically marks all participants in pids as checked-in under a single lock acquire, writing participants.csv exactly once. Only participants that were not already checked in count toward CheckedIn; the file is only written when at least one participant was actually toggled.
func (*Store) DeleteCompetition ¶
func (*Store) DeleteCompetitionFile ¶ added in v0.17.0
DeleteCompetitionFile removes a single draw artifact from a competition directory. Returns nil when the file does not exist (idempotent delete). Only filenames in the allowedDrawFiles set are accepted.
func (*Store) DeleteTeamLineup ¶
DeleteTeamLineup removes the lineup for (teamID, round) if present. Lineups are always deletable, including while a match is running.
Returns nil when no entry exists (idempotent delete).
func (*Store) DeleteTeamLineupForMatch ¶ added in v0.17.0
DeleteTeamLineupForMatch removes the match-scoped lineup for (teamID, matchID) if present (mp-825). Lineups are always deletable, including while a match is running.
Returns nil when no entry exists (idempotent delete).
func (*Store) FileMtime ¶
FileMtime returns the UnixNano mtime of a file inside a competition directory. Returns 0 if the file does not exist or stat fails.
func (*Store) ListCompetitions ¶
func (*Store) LoadCompetition ¶
func (s *Store) LoadCompetition(id string) (*Competition, error)
func (*Store) LoadCompetitorStatus ¶
LoadCompetitorStatus returns the per-player status map for compID. A missing file is treated as "all eligible" per FR-034 / NFR-025.
Uses the per-competition lock (consistent with pools/bracket/etc.) so a concurrent ineligibility write on a different competition does not serialize behind this read.
func (*Store) LoadParticipants ¶
LoadParticipants loads participants with seeds merged (default behavior).
func (*Store) LoadParticipantsOpt ¶
func (s *Store) LoadParticipantsOpt(compID string, withZekkenName bool, opts LoadParticipantsOpts) ([]domain.Player, error)
LoadParticipantsOpt loads participants with configurable options.
func (*Store) LoadPoolMatches ¶
func (s *Store) LoadPoolMatches(compID string) ([]MatchResult, error)
func (*Store) LoadPoolMatchesLocked ¶
func (s *Store) LoadPoolMatchesLocked(compID string) ([]MatchResult, error)
LoadPoolMatchesLocked loads pool matches WITHOUT acquiring the per-competition lock. Caller MUST already hold the write lock for this competition, typically from inside a transform passed to UpdatePoolMatchByID, UpdateBracket, or UpdateCompetitionChanged. Bypasses the cache deliberately: the cache mtime can lag a concurrent writer that the caller may be in the middle of making, and we want the most-recent on-disk state.
Motivating use case: MaybeAutoCompletePools (engine/competition.go) re-checks "are all matches completed?" INSIDE its UpdateCompetitionChanged transform to close a TOCTOU window where the outer LoadPoolMatches snapshot can go stale. The transform holds the per-comp write lock, so the standard LoadPoolMatches would deadlock (sync.RWMutex non-recursive); this helper provides the lock-free read for that context.
func (*Store) LoadSchedule ¶
func (s *Store) LoadSchedule(compID string) ([]ScheduleEntry, error)
func (*Store) LoadSeeds ¶
func (s *Store) LoadSeeds(compID string) ([]domain.SeedAssignment, error)
LoadSeeds and SaveSeeds use the PER-COMPETITION lock (not the store-wide `s.mu`) so they serialize against other per-comp readers/writers. In particular against the StartCompetition transform held by UpdateCompetitionChanged. Pre-fix, SaveSeeds took `s.mu.Lock()` (store-wide) and the StartCompetition transform took the per-comp lock, so the seeds drift check inside the transform (via FileMtime) had a race window: a concurrent SaveSeeds could land AFTER the mtime check but BEFORE the status commit, leaving status=Pools on disk with seeds.csv reflecting roster the engine never read.
Switching to per-comp locking ALSO improves scalability; concurrent seed saves for DIFFERENT comps no longer block each other on the global store mutex. Same locking strategy participants.csv and pools.csv already use.
func (*Store) LoadTeamLineups ¶
LoadTeamLineups returns every lineup persisted for compID, keyed by "<teamID>-<round>". A missing file is treated as "no lineups yet" and returns an empty map (consistent with LoadCompetitorStatus).
Cache-aware (mtime-keyed via loadCached, same as LoadBracket): repeated reads within the same mtime are served from memory. loadCached itself takes the per-competition read lock, so the explicit RLock in the old body is not lost, just moved. loadCached also validates compID, so no separate ValidateCompetitionID call is needed here (mirrors LoadBracket). Returns a deep copy so callers can mutate the map freely.
func (*Store) LoadTournament ¶
func (s *Store) LoadTournament() (*Tournament, error)
func (*Store) ReplaceParticipant ¶ added in v0.17.0
func (s *Store) ReplaceParticipant(compID string, pid string, withZekkenName bool, transform func(p *domain.Player) error) (*domain.Player, error)
ReplaceParticipant is the setup-gated rename/replace path used by the PUT /competitions/:id/participants/:pid handler. It applies transform under the per-comp lock with the same status re-check as AddParticipant, so a concurrent start can't sneak in between the handler's outer 409 check and the file write. UpdateParticipant remains gateless because check-in toggles must keep working after the competition is running.
func (*Store) ResetOverrides ¶
func (*Store) ResetOverridesChanged ¶
ResetOverridesChanged clears all overrides and reports whether the file changed (false when overrides were already empty).
func (*Store) RunningMatchOnCourt ¶ added in v0.17.0
func (s *Store) RunningMatchOnCourt(court, skipCompID string) (*CourtOccupancy, error)
RunningMatchOnCourt scans every competition for a match with the given court that is currently in MatchStatusRunning. It returns the first occupant found, or nil if the court is free. An empty court string is never considered busy (unassigned matches don't block anything).
Lock discipline: each competition's data is loaded under its own per-comp READ lock (via the public Load* methods). The caller MUST NOT already hold the write lock for any competition that this method scans, that would deadlock the non-reentrant RWMutex. On the StartMatchTx path the caller holds compID_X's write lock, so skipCompID must be set to compID_X; that competition is checked by the caller via StoreTx instead.
func (*Store) SaveCompetition ¶
func (s *Store) SaveCompetition(c *Competition) error
func (*Store) SaveCompetitionChanged ¶
func (s *Store) SaveCompetitionChanged(c *Competition) (bool, error)
SaveCompetitionChanged persists c and reports whether the on-disk content actually changed. Use this instead of SaveCompetition when you need to gate a broadcast on a real mutation.
func (*Store) SaveParticipants ¶
SaveParticipants persists the roster. It loads the competition under the per-comp lock to determine WithZekkenName so the on-disk CSV layout matches what the next LoadParticipants(_, comp.WithZekkenName) call will read; passing the wrong column count corrupts the file on the next round-trip.
func (*Store) SavePoolMatches ¶
func (s *Store) SavePoolMatches(compID string, results []MatchResult) error
func (*Store) SaveRankOverride ¶
func (*Store) SaveRankOverrideChanged ¶
SaveRankOverrideChanged saves the rank override and reports whether the overrides file actually changed. Use this to gate broadcasts.
func (*Store) SaveSchedule ¶
func (s *Store) SaveSchedule(compID string, entries []ScheduleEntry) error
func (*Store) SaveScheduleChanged ¶
func (s *Store) SaveScheduleChanged(compID string, entries []ScheduleEntry) (bool, error)
SaveScheduleChanged persists entries and reports whether the on-disk content actually changed. Use this instead of SaveSchedule when you need to gate a broadcast on a real mutation.
func (*Store) SaveSeeds ¶
func (s *Store) SaveSeeds(compID string, assignments []domain.SeedAssignment) error
func (*Store) SaveTournament ¶
func (s *Store) SaveTournament(t *Tournament) error
func (*Store) SaveTournamentChanged ¶
func (s *Store) SaveTournamentChanged(t *Tournament) (bool, error)
SaveTournamentChanged persists t and reports whether the on-disk content actually changed. Use this instead of SaveTournament when you need to gate a broadcast on a real mutation.
func (*Store) SaveWinnerOverride ¶
func (*Store) SetCompetitorStatus ¶
func (s *Store) SetCompetitorStatus(compID string, status domain.CompetitorStatus) error
SetCompetitorStatus persists a status entry, replacing any prior entry for the same PlayerID. RecordedAt defaults to time.Now().UTC() when the caller leaves it zero.
Uses the per-competition lock so the load-mutate-save cycle is atomic against other competitor-status writers for the same competition.
func (*Store) SetTeamLineup ¶
SetTeamLineup validates and persists a lineup, replacing any prior entry for the same (teamID, round). The caller MUST pass the competition's team size so ValidatePositions can enforce the FIK position-key rules. Lineups are always editable, including while a match is running or completed.
FR-040, FR-041 / R4 / CHK012.
func (*Store) UpdateBracket ¶
UpdateBracket atomically loads the bracket for compID, calls mutate with the loaded bracket (always non-nil, parseBracketFile returns an empty `&Bracket{Rounds: [][]BracketMatch{}}` when no file exists yet; so callers can rely on a non-nil receiver and an empty Rounds slice as the "no bracket yet" sentinel), and, if mutate returns nil, persists the bracket. The entire load + mutate + save sequence runs under the per-competition lock so concurrent calls serialize correctly.
mutate may modify the bracket arbitrarily (e.g. update one match AND propagate the winner to the next round), this is the more general primitive that supports recordBracketMatchResult's propagateBracketWinner behavior. For single-match mutations, see also engine.withBracketMatch which delegates to this.
If mutate returns a non-nil error, no write happens and the error is returned unchanged (callers can use errors.Is to discriminate not-found vs validation vs I/O). Importantly, returning errors from mutate is how callers signal "match not found, don't save the unchanged bracket back", the alternative ("found" bool) would either save unnecessarily or duplicate the not-found error path at every caller.
IMPORTANT: mutate runs while this method holds the per-competition lock. It MUST NOT call any other Store method that acquires the same lock (SavePoolMatches, SaveBracket, SaveCompetitionChanged, recursive UpdateBracket / UpdatePoolMatchByID / UpdateBracket calls, etc.), `sync.Mutex` is non-recursive and would deadlock.
func (*Store) UpdateCompetitionChanged ¶
func (s *Store) UpdateCompetitionChanged(id string, transform func(current *Competition) (*Competition, error)) (bool, error)
UpdateCompetitionChanged atomically loads the competition with id, calls transform with the loaded record (which may be nil if no file exists yet), and if transform returns a non-nil *Competition persists it. Returns (changed, err) where changed reports whether the on-disk content actually differs from the prior version (same semantics as SaveCompetitionChanged). The entire load + transform + save sequence runs under the per-competition lock so concurrent calls serialize correctly without losing each other's mutations.
transform's return value selects what to save:
- return current (after mutating in place), nil → save the mutated record
- return a NEW *Competition, nil → save that (use this for the PUT-create case where current is nil and the body becomes the new on-disk state)
- return nil, nil → skip the save (no-op, used when the precondition the caller wanted to commit on is no longer true)
- return _, err → propagate the error unchanged; no save
Pre-atomic-primitive, handlers and engine code called LoadCompetition + SaveCompetitionChanged sequentially with no shared lock between the two calls. A concurrent writer could change the on-disk state between Load and Save; the late save would clobber that change with stale data. Specific failure modes this fix closes:
- POST /invalidate vs. concurrent score-save's MaybeAutoCompletePools: admin's "invalid" status overwritten back to "complete" (or vice versa).
- PUT /competitions/:id uniqueness-check race: two new competitions with the same name both pass the check and both land.
- StartCompetition vs. concurrent edit of the same competition.
IMPORTANT: transform runs while this method holds the per-competition lock. It MUST NOT call any other Store method that acquires the same lock (SaveCompetition, SaveCompetitionChanged, SaveParticipants, SavePools, SaveBracket, SavePoolMatches, recursive UpdateCompetitionChanged / UpdateBracket / UpdatePoolMatchByID, etc.); `sync.Mutex` is non-recursive and would deadlock. For cross-file operations (e.g. participants save), perform them AFTER UpdateCompetitionChanged returns.
func (*Store) UpdateParticipant ¶
func (s *Store) UpdateParticipant(compID string, pid string, withZekkenName bool, transform func(p *domain.Player) error) (*domain.Player, error)
UpdateParticipant atomically loads the participant list, applies transform to the target player, and persists the result. Used to avoid TOCTOU races on concurrent check-ins.
func (*Store) UpdatePoolMatchByID ¶
func (s *Store) UpdatePoolMatchByID(compID, matchID string, mutate func(*MatchResult)) (bool, error)
UpdatePoolMatchByID atomically loads the pool-matches CSV for compID, finds the match with matchID, calls mutate on it, and persists the updated slice. Returns (found, err): found is false when no match has that ID, allowing callers to fall through (e.g. to the bracket store for elimination-round matches).
The entire load + find + mutate + save sequence runs under the per-competition lock so concurrent calls, even for different match IDs in the same competition, serialize correctly without losing each other's mutations.
Without this primitive, the equivalent engine helper (engine.withPoolMatch) had a TOCTOU window: two operators scoring different matches on different courts could each LoadPoolMatches into separate copies, mutate their target match, and SavePoolMatches in sequence, the later save would overwrite the earlier save's mutation with stale data for the OTHER match. One operator's score would be silently lost during a live tournament.
func (*Store) UpdateTournamentChanged ¶
func (s *Store) UpdateTournamentChanged(desired *Tournament, transform func(current *Tournament, desired *Tournament) error) (bool, error)
UpdateTournamentChanged atomically loads the current tournament under the store's write lock, calls transform(current, desired) which may modify desired in place (e.g. to preserve fields from current), and - if transform returns nil, persists desired. Returns (changed, err) like SaveTournamentChanged.
This is the race-free primitive for "load the existing record, copy some fields forward, save the result." Without it, a handler that calls LoadTournament + SaveTournamentChanged sequentially has a TOCTOU window between the two calls during which a concurrent writer can land changes that the load-modify-save then clobbers.
Specifically motivated by the PUT /api/tournament password-preserve semantics: when the incoming body sends Password == "", the handler must copy the stored Password into the desired record. Two concurrent PUTs, one with empty Password (intent: keep), one with a new password (intent: change), could race in the old code so that the empty-Password PUT's late save clobbers the change-Password PUT's earlier save. With this method, the load + transform + save sequence is serialized under the store's lock.
`current` is nil ONLY when the tournament.md file does not exist yet (first-ever save). When the file exists but parses cleanly, `current` holds the parsed record. When the file exists but the front matter is corrupt, the load below falls back to a default Tournament record (matching LoadTournament's behavior) and that default is passed to `transform`,`current` is still non-nil in that case. transform must handle both cases. If transform returns a non-nil error, no write happens and the error is returned to the caller as-is (callers can use errors.Is to discriminate validation vs. I/O).
IMPORTANT: transform runs while this method holds both s.mu and s.tournamentMu (non-recursive locks). It MUST NOT call back into any Store method that acquires either lock, that includes LoadTournament, SaveTournament, SaveTournamentChanged, and a recursive UpdateTournamentChanged. Deadlock would result. The transform should only mutate `desired` (possibly by copying fields from `current`) and return; for any cross-resource coordination (e.g. updating a competition during a tournament update), perform the load BEFORE calling this method and pass the resulting data in via a closure over local variables.
func (*Store) WithCompetitionRenameLock ¶
WithCompetitionRenameLock runs fn while holding the store's rename-coordination mutex (s.compRenameMu). Use it to wrap a competition uniqueness-check + save sequence so two concurrent renames of DIFFERENT competitions to the SAME new name can't both pass the check (each seeing the other still has its old name) and both land, leaving two competitions with the same effective Name.
The mutex is finer-grained than s.mu (which covers all store state) and coarser than getCompLock(id) (which covers a single competition). It only serializes "name-uniqueness" operations against each other; per-competition score saves, schedule edits, override-rank, etc. remain fully concurrent across competitions.
Lock ordering note: fn typically calls LoadCompetition on OTHER competitions (via checkUniqueCompName) and SaveCompetitionChanged on THIS competition. Both of those take per-comp locks internally, safe because s.compRenameMu is a different mutex from any per-comp lock, and the per-comp locks are acquired one at a time in fn. No AB-BA deadlock is possible because s.compRenameMu serializes the outer operation.
IMPORTANT: s.compRenameMu is a sync.Mutex (non-recursive). fn MUST NOT recursively call WithCompetitionRenameLock, that would deadlock on the second acquire. Same advisory as the Update*Changed family: transforms / closures running under a store mutex should perform only the load + check + save work for the resource they're locking, not invoke other lock-acquiring Store methods for the SAME lock. Calls into methods that acquire OTHER locks (per-comp via SaveCompetitionChanged / LoadCompetition for different IDs) are fine, those locks are independent.
func (*Store) WithCourtExclusivityLock ¶ added in v0.17.0
WithCourtExclusivityLock runs fn while holding the store's court-exclusivity mutex. Use this to wrap a cross-competition court-busy check followed by a per-competition WithTransaction call, so two concurrent match-starts on the same court in different competitions can't both pass the check before either commits (TOCTOU).
Lock ordering: courtCheckMu is acquired BEFORE any per-comp lock. fn MUST NOT recursively call WithCourtExclusivityLock, non-reentrant.
func (*Store) WithTransaction ¶
WithTransaction runs fn under the per-competition write lock for compID. fn receives a StoreTx that can call multiple load/save methods without re-acquiring the lock, the lock is held for the entire fn body and released exactly once on return (success OR error).
Crash-atomicity. Most saves invoked through tx are STAGED into a per-transaction write-ahead log (see internal/state/wal) instead of going straight to disk. After fn returns nil, this method Commits the WAL (atomic-renames the intent file into <data>/.wal/), then Applies the staged writes one-by-one to their target files, then deletes the WAL file. A crash after Commit but before all Applies finish leaves the WAL on disk for replay on next start (Store.NewStore scans the directory); a crash before Commit leaves no on-disk trace and the partial in-memory work is dropped. Multi-file transactions that previously could land file A but not file B now either land both or replay both, cross-file atomicity across a process crash (closing the v3 review A1 finding). Exception: UpdateParticipant is NOT WAL-staged (see WAL caveat below); it writes immediately and is not rolled back on error.
Lock semantics. Per-comp lock is held for the entire fn body AND across Commit + Apply, so other writers see the WAL transition from "absent" → "applied" → "absent" as an atomic event.
fn MUST call methods on tx, NOT on the underlying *Store directly. The per-comp mutex is a non-recursive sync.RWMutex; a direct s.Save* call from inside fn would re-acquire and deadlock.
WAL caveat. StoreTx.UpdateParticipant writes directly via atomic rename (not through the WAL). It is crash-safe per-file but is NOT staged, so if fn returns an error after calling UpdateParticipant the participant write is NOT rolled back. Do not mix UpdateParticipant with WAL-staged tx saves (e.g. tx.SaveCompetition) and expect cross-file crash-atomicity, each write lands independently.
fn read-after-write within the same tx. Tx-internal reads (tx.LoadCompetition, tx.LoadBracket, etc.) read from disk via the *Locked helpers and DO NOT see the WAL-staged writes, the on-disk file isn't updated until Apply runs after fn returns. The current engine paths (RecordMatchResultWithIneligibilityTx, RecordDecisionTx, K3 rollback) read BEFORE they write within a single tx and never read-after-write the same file, so this limitation is invisible to them. If a future tx body needs to read its own pending write, the WAL exposes Intents(), but that's a code-smell and probably indicates the load/save should be re-ordered.
T155, NFR-010, T210/T211/T212 (A1 WAL).
type StoreTx ¶
type StoreTx interface {
LoadCompetition(compID string) (*Competition, error)
SaveCompetition(c *Competition) error
LoadPools(compID string) ([]helper.Pool, error)
SavePools(compID string, pools []helper.Pool) error
LoadPoolMatches(compID string) ([]MatchResult, error)
SavePoolMatches(compID string, matches []MatchResult) error
LoadBracket(compID string) (*Bracket, error)
SaveBracket(compID string, b *Bracket) error
LoadCompetitorStatus(compID string) (map[string]domain.CompetitorStatus, error)
SetCompetitorStatus(compID string, status domain.CompetitorStatus) error
LoadTeamLineups(compID string) (map[string]domain.TeamLineup, error)
SetTeamLineup(compID string, l domain.TeamLineup, teamSize int) error
LoadParticipants(compID string, withZekkenName bool) ([]domain.Player, error)
// UpdatePoolMatchByID is the tx-aware twin of
// Store.UpdatePoolMatchByID. Same semantics, same return values; the
// only difference is that it skips re-acquiring the per-comp lock
// (already held by WithTransaction). Score / decision handlers (T156)
// use this to keep their match-result write under the SAME lock
// acquire as the competitor-status side effects.
UpdatePoolMatchByID(compID, matchID string, mutate func(*MatchResult)) (bool, error)
// UpdateBracket is the tx-aware twin of Store.UpdateBracket. The
// mutate closure may modify the bracket arbitrarily and signal "match
// not found" by returning an error (typically wrapping the engine's
// match-not-found sentinel, see engine.withBracketMatch).
UpdateBracket(compID string, mutate func(*Bracket) error) error
// UpdateParticipant is the tx-aware twin of
// Store.updateParticipantNoLock. Applies transform to the target
// participant while the transaction lock is held, so the caller
// can serialise a competition-status pre-check (via LoadCompetition
// in the same tx body) and the participant write under one lock
// acquire. Participants.csv and seeds.csv are written directly via
// atomic rename, they are not staged through the WAL, but each
// write is individually crash-safe, and no other WAL-staged file
// is touched by this path, so cross-file atomicity is not required.
UpdateParticipant(compID, pid string, withZekkenName bool, transform func(*domain.Player) error) (*domain.Player, error)
}
StoreTx is the transactional handle passed to fn in WithTransaction. Methods mirror the corresponding *Store methods but DO NOT re-acquire the per-competition lock, that's already held by WithTransaction.
The compID parameter on each method is intentional duplication: it keeps StoreTx methods source-compatible with their *Store siblings, so the migration path for a handler is "wrap in WithTransaction + replace `store.` with `tx.`" with no further rewrites. The transaction is bound to a single competition (passed to WithTransaction); every StoreTx method guards the supplied compID against the bound one and returns ErrMismatchedTxCompID on mismatch, so a stale or wrong ID surfaces as a normal error instead of silently doing unlocked I/O against another competition's files.
type SubMatchResult ¶
type SubMatchResult struct {
Position int `json:"position"`
SideA string `json:"sideA"`
SideB string `json:"sideB"`
IpponsA []string `json:"ipponsA"`
IpponsB []string `json:"ipponsB"`
HansokuA int `json:"hansokuA"`
HansokuB int `json:"hansokuB"`
Winner string `json:"winner"`
Decision string `json:"decision"`
DecidedByHantei bool `json:"decidedByHantei,omitempty" yaml:"decided_by_hantei,omitempty"`
Encho *EnchoMetadata `json:"encho,omitempty" yaml:"encho,omitempty"`
}
type TeamMatchType ¶
type TeamMatchType string
TeamMatchType selects the team-match format. FR-044.
- TeamMatchTypeFixed: every N×1 bout is scheduled up-front by position (Senpo×Senpo, Jiho×Jiho, …). This is the historical default and the empty value resolves to it for backward compat.
- TeamMatchTypeKachinuki: only the first bout is scheduled. After each bout, the winner stays on and faces the next un-retired player from the losing side; on a hikiwake both retire. The team match ends when one side has no remaining un-retired players. See engine/kachinuki.go.
const ( TeamMatchTypeFixed TeamMatchType = "fixed" TeamMatchTypeKachinuki TeamMatchType = "kachinuki" )
type TeamResultLine ¶ added in v1.0.0
type TeamResultLine struct {
ShiroIV int `json:"shiroIV"`
AkaIV int `json:"akaIV"`
ShiroPW int `json:"shiroPW"`
AkaPW int `json:"akaPW"`
}
TeamResultLine is the authoritative team-match summary attached to the wire payload (HTTP responses and SSE match_updated events) so the frontend renders individual victories (IV) and points won (PW) directly rather than re-deriving them from sub-bouts. Shiro is SideB (rendered left), Aka is SideA (rendered right), matching the display convention used across the app.
func TeamResultFrom ¶ added in v1.0.0
func TeamResultFrom(subResults []SubMatchResult, sideAName, sideBName string) *TeamResultLine
TeamResultFrom aggregates sub-bouts into IV and PW per side. It is the single source of truth for the team-match summary: the daihyosen placeholder (Position <= DaihyosenSubPosition, the -1 daihyosen or any negative) is skipped so a re-validated tie does not double-count, IV counts sub-bout winners via the same side-matching fallback as scoring (winner may carry the match-level or sub-level side name), and PW counts every scored ippon regardless of bout outcome (a drawn bout where both sides scored still contributes), skipping unfilled "•" placeholder slots via countScoringIppons. SideA is Aka, SideB is Shiro. Returns nil when there are no countable sub-bouts (an individual match, or a slice containing only the daihyosen placeholder with Position == DaihyosenSubPosition (-1)). engine.ComputeTeamSummary delegates here.
type Theme ¶ added in v0.17.0
type Theme struct {
PrimaryColor string `yaml:"primary_color,omitempty" json:"primaryColor,omitempty"`
AccentSoftColor string `yaml:"accent_soft_color,omitempty" json:"accentSoftColor,omitempty"`
WindowTitle string `yaml:"window_title,omitempty" json:"windowTitle,omitempty"`
LogoPath string `yaml:"logo_path,omitempty" json:"-"` // disk filename; served via /api/branding/logo
}
Theme holds per-tournament branding overrides (mp-scf). PrimaryColor and AccentSoftColor are CSS hex values (#rrggbb). WindowTitle overrides the browser tab/window title; it defaults to "Bracket Creator Mobile" when empty. LogoPath stores the uploaded logo filename under tournament-data/branding/; it is NOT exposed in JSON responses (the logo is served via GET /api/branding/logo instead). All fields are optional; omit the whole block for the default styling.
type Tournament ¶
type Tournament struct {
Name string `yaml:"name" json:"name"`
Date string `yaml:"date" json:"date"`
Venue string `yaml:"venue" json:"venue"`
Courts []string `yaml:"courts" json:"courts"`
Password string `yaml:"password" json:"password"`
// DurationDays is the number of consecutive calendar days this
// tournament spans, starting from Date (Day 1). Default 1 (single-day).
// Maximum 30. Use Days() to obtain the derived per-day DD-MM-YYYY list.
//
// omitempty drops the field only when the value is 0 (Go's zero value),
// never when it is 1; so tournaments saved by this code always persist
// an explicit duration_days. The omitempty matters in the reverse
// direction: legacy tournament.md files predating this field carry no
// duration_days key, so they deserialize to 0 and are migrated to 1 by
// the load path (and ApplyTournamentDefaults).
DurationDays int `yaml:"duration_days,omitempty" json:"durationDays,omitempty"`
// AdminPassword gates destructive operations (spec 004 / mp-e21):
// competition delete, draw/override/invalidate, and participant roster
// mutations. It is a SEPARATE, higher-privilege credential from
// Password (which gates the API as a whole).
//
// CRITICAL: it is WRITE-ONLY at the API boundary; `json:"-"` means it
// is never emitted in any response AND never populated by binding a
// request body into a Tournament. That is the opposite of Password
// (which is a peer of the credential gating /tournament, so returning
// it is harmless). AdminPassword is HIGHER privilege than that gate, so
// leaking it via GET; or letting a main-password holder overwrite it
// via the bulk PUT; would collapse the separation. It is set only via
// the dedicated, elevated-gated PUT /api/auth/admin-password handler.
// File mode only; in locked mode the env-var bcrypt hash is
// authoritative and any on-disk value here is inert.
AdminPassword string `yaml:"admin_password,omitempty" json:"-"`
// Ceremony blocks expressed as human duration strings (e.g. "30m",
// "1h"). When set, the auto-scheduler reserves a contiguous range
// at the appropriate point in the day and skips match slots that
// would land inside it. Optional; zero/empty means no block.
// FR-056, R9, data-model §6.
OpeningBlock string `yaml:"opening_block,omitempty" json:"openingBlock,omitempty"`
LunchBlock string `yaml:"lunch_block,omitempty" json:"lunchBlock,omitempty"`
ClosingBlock string `yaml:"closing_block,omitempty" json:"closingBlock,omitempty"`
// ClockToElapsedMultiplier scales the on-clock match duration to
// "real elapsed minutes"; coin tosses, scoring transitions, salutes
// and crossings, etc. Defaults to 1.5 via ApplyTournamentDefaults
// when zero. FR-055, R9.
ClockToElapsedMultiplier float64 `yaml:"clock_to_elapsed_multiplier,omitempty" json:"clockToElapsedMultiplier,omitempty"`
// SlowestCourtBufferPct is the % buffer added when distributing total
// elapsed minutes across N parallel courts; the slowest court usually
// runs longer than the mean. Defaults to 10 via ApplyTournamentDefaults
// when zero. FR-057, R9.
SlowestCourtBufferPct int `yaml:"slowest_court_buffer_pct,omitempty" json:"slowestCourtBufferPct,omitempty"`
// Mode selects the auth posture for the whole tournament, chosen at
// creation and IMMUTABLE thereafter (mp-7h7). "officiated" (default)
// gates the full admin surface behind X-Tournament-Password.
// "self-run" inverts the boundary: constructive actions (scoring,
// check-in, start/complete) are public; only destructive actions
// (those already gated by RequireElevatedPassword / enforceElevated)
// still require X-Admin-Password. omitempty means older tournament.md
// files (Mode == "") normalise to "officiated" via ApplyTournamentDefaults.
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
// Public tournament info fields (mp-ef3). All optional (omitempty).
// Rendered read-only on the viewer home page; editable in the admin setup form.
// PublicURL is the externally-shareable base URL for this tournament (mp-s1gl).
// When set it overrides window.location.origin for QR codes and share links.
PublicURL string `yaml:"public_url,omitempty" json:"publicURL,omitempty"`
VenueAddress string `yaml:"venue_address,omitempty" json:"venueAddress,omitempty"`
VenueMapURL string `yaml:"venue_map_url,omitempty" json:"venueMapURL,omitempty"`
OpeningTime string `yaml:"opening_time,omitempty" json:"openingTime,omitempty"`
ClosingTime string `yaml:"closing_time,omitempty" json:"closingTime,omitempty"`
WebsiteURL string `yaml:"website_url,omitempty" json:"websiteURL,omitempty"`
AwardsNote string `yaml:"awards_note,omitempty" json:"awardsNote,omitempty"`
InfoNotes string `yaml:"info_notes,omitempty" json:"infoNotes,omitempty"`
Contacts []TournamentContact `yaml:"contacts,omitempty" json:"contacts,omitempty"`
// RulesURLLegacy captures the pre-rename `rules_url` YAML key from
// tournament.md files written before the field was renamed to
// website_url. It is migrated into WebsiteURL by ApplyTournamentDefaults
// and is never emitted on the wire (json:"-") nor re-persisted (the
// migration clears it, so omitempty drops the old key on the next save).
// Field-based capture + migrate keeps the change additive: no custom
// Tournament.UnmarshalYAML that would have to enumerate every field.
RulesURLLegacy string `yaml:"rules_url,omitempty" json:"-"`
// Sponsors is the ordered list of sponsor banners shown on the public
// viewer home page only (the /display TV/lobby surfaces intentionally
// omit them; mp-c38/mp-vb3u). Stored as omitempty so legacy tournament.md
// files without sponsors round-trip cleanly (no `sponsors: []` key emitted).
Sponsors []Sponsor `yaml:"sponsors,omitempty" json:"sponsors,omitempty"`
// Theme holds optional branding overrides: custom accent colors and a
// tournament logo (mp-scf). All fields are omitempty so existing
// tournament.md files without a theme block round-trip cleanly.
Theme *Theme `yaml:"theme,omitempty" json:"theme,omitempty"`
}
func (*Tournament) Days ¶ added in v0.17.0
func (t *Tournament) Days() []string
Days returns the ordered list of DD-MM-YYYY calendar day strings covered by the tournament, derived from Date + DurationDays. The list has exactly DurationDays entries (Day 1 = Date, Day 2 = Date+1, …).
Edge cases; never panics:
- If Date is empty or unparseable, returns nil (no day list available).
- If DurationDays < 1, returns nil.
Consumers should call ApplyTournamentDefaults before Days() to ensure DurationDays has its correct minimum of 1.
type TournamentContact ¶ added in v0.17.0
type TournamentContact struct {
Label string `yaml:"label" json:"label"`
Value string `yaml:"value" json:"value"`
}
TournamentContact is a single contact entry for attendees (mp-ef3). Label is a short description (e.g. "Email", "Phone") and Value is the contact detail (email address, phone number, URL, etc.).
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package wal implements a tiny write-ahead log so that state.Store.WithTransaction can commit MULTIPLE on-disk files atomically across a process crash.
|
Package wal implements a tiny write-ahead log so that state.Store.WithTransaction can commit MULTIPLE on-disk files atomically across a process crash. |