diff --git a/field/team_sign.go b/field/team_sign.go index 9d057e67..99764a19 100644 --- a/field/team_sign.go +++ b/field/team_sign.go @@ -1,7 +1,7 @@ // Copyright 2024 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) // -// Models and logic for controlling a Cypress team number / timer sign. +//go:build !custom package field diff --git a/field/team_sign_custom.go b/field/team_sign_custom.go new file mode 100644 index 00000000..03d03831 --- /dev/null +++ b/field/team_sign_custom.go @@ -0,0 +1,376 @@ +//go:build custom + +package field + +import ( + "fmt" + "github.com/Team254/cheesy-arena/game" + "image/color" + "log" + "net" + "strconv" + "strings" + "time" +) + +// Represents a collection of team number and timer signs. +type TeamSigns struct { + Red1 TeamSign + Red2 TeamSign + Red3 TeamSign + RedTimer TeamSign + Blue1 TeamSign + Blue2 TeamSign + Blue3 TeamSign + BlueTimer TeamSign +} + +// Represents a team number or timer sign. +type TeamSign struct { + isTimer bool + address byte + nextMatchTeamId int + frontText string + frontColor color.RGBA + rearText string + lastFrontText string + lastFrontColor color.RGBA + lastRearText string + udpConn net.Conn + packetData [128]byte + packetIndex int + lastPacketTime time.Time +} + +const ( + teamSignAddressPrefix = "10.0.100." + teamSignYear = 2026 + teamSignPort = 10011 + teamSignPacketMagicString = "CYPRX" + teamSignPacketHeaderLength = 7 + teamSignCommandSetDisplay = 0x04 + teamSignAddressSingle = 0x01 + teamSignPacketTypeFrontText = 0x01 + teamSignPacketTypeRearText = 0x02 + teamSignPacketTypeFrontIntensity = 0x03 + teamSignPacketTypeColor = 0x04 + teamSignPacketPeriodMs = 5000 + teamSignBlinkPeriodMs = 750 + teamSignRearTextLength = 20 +) + +// Predefined colors for the team sign front text. The "A" channel is used as the intensity. +var redColor = color.RGBA{255, 0, 0, 255} +var blueColor = color.RGBA{0, 50, 255, 255} +var greenColor = color.RGBA{0, 255, 0, 255} +var orangeColor = color.RGBA{255, 50, 0, 255} +var purpleColor = color.RGBA{0, 255, 0, 255} // Fallback to green or similar if needed +var whiteColor = color.RGBA{255, 200, 180, 255} + +// Creates a new collection of team signs. +func NewTeamSigns() *TeamSigns { + signs := new(TeamSigns) + signs.RedTimer.isTimer = true + signs.BlueTimer.isTimer = true + return signs +} + +// Updates the state of all signs with the latest data and sends packets to the signs if anything has changed. +func (signs *TeamSigns) Update(arena *Arena) { + // Generate the countdown string which is used in multiple places. + matchTimeSec := int(arena.MatchTimeSec()) + currentTime := time.Now() + var countdownSec int + switch arena.MatchState { + case PreMatch: + if arena.AudienceDisplayMode == "allianceSelection" { + countdownSec = arena.AllianceSelectionTimeRemainingSec + } else { + countdownSec = game.MatchTiming.AutoDurationSec + } + case StartMatch: + countdownSec = game.MatchTiming.AutoDurationSec + case AutoPeriod: + countdownSec = game.MatchTiming.AutoDurationSec - matchTimeSec + case TeleopPeriod: + countdownSec = game.MatchTiming.AutoDurationSec + game.GetTeleopDurationSec() + + game.MatchTiming.PauseDurationSec - matchTimeSec + case TimeoutActive: + countdownSec = game.MatchTiming.TimeoutDurationSec - matchTimeSec + default: + countdownSec = 0 + } + countdown := fmt.Sprintf("%02d:%02d", countdownSec/60, countdownSec%60) + rearCountdown := fmt.Sprintf("%d:%02d", countdownSec/60, countdownSec%60) + + // Generate the in-match rear text which is common to a whole alliance. + redInMatchTeamRearText := generateInMatchTeamRearText(arena, true, rearCountdown, currentTime) + redInMatchTimerRearText := generateInMatchTimerRearText(arena, true, rearCountdown) + blueInMatchTeamRearText := generateInMatchTeamRearText(arena, false, rearCountdown, currentTime) + blueInMatchTimerRearText := generateInMatchTimerRearText(arena, false, rearCountdown) + + signs.Red1.update(arena, "R1", true, countdown, redInMatchTeamRearText) + signs.Red2.update(arena, "R2", true, countdown, redInMatchTeamRearText) + signs.Red3.update(arena, "R3", true, countdown, redInMatchTeamRearText) + signs.RedTimer.update(arena, "", true, countdown, redInMatchTimerRearText) + signs.Blue1.update(arena, "B1", false, countdown, blueInMatchTeamRearText) + signs.Blue2.update(arena, "B2", false, countdown, blueInMatchTeamRearText) + signs.Blue3.update(arena, "B3", false, countdown, blueInMatchTeamRearText) + signs.BlueTimer.update(arena, "", false, countdown, blueInMatchTimerRearText) +} + +// Sets the team numbers for the next match on all signs. +func (signs *TeamSigns) SetNextMatchTeams(teams [6]int) { + signs.Red1.nextMatchTeamId = teams[0] + signs.Red2.nextMatchTeamId = teams[1] + signs.Red3.nextMatchTeamId = teams[2] + signs.Blue1.nextMatchTeamId = teams[3] + signs.Blue2.nextMatchTeamId = teams[4] + signs.Blue3.nextMatchTeamId = teams[5] +} + +// Sets the IP address of the sign. +func (sign *TeamSign) SetId(id int) { + if sign.udpConn != nil { + if err := sign.udpConn.Close(); err != nil { + log.Printf("Failed to close team sign connection: %v", err) + } + } + sign.address = byte(id) + if id == 0 { + // The sign is not configured. + return + } + ipAddress := fmt.Sprintf("%s%d", teamSignAddressPrefix, id) + + var err error + sign.udpConn, err = net.Dial("udp4", fmt.Sprintf("%s:%d", ipAddress, teamSignPort)) + if err != nil { + log.Printf("Failed to connect to team sign at %s: %v", ipAddress, err) + return + } + addressParts := strings.Split(ipAddress, ".") + if len(addressParts) != 4 { + log.Printf("Failed to configure team sign: invalid IP address: %s", ipAddress) + return + } + address, err := strconv.Atoi(addressParts[3]) + if err != nil { + log.Printf("Failed to configure team sign: invalid IP address: %s", ipAddress) + return + } + sign.address = byte(address) + + // Reset the sign's state to ensure that the next packet sent will update the sign. + sign.packetIndex = 0 + sign.lastPacketTime = time.Time{} +} + +// Updates the sign's internal state with the latest data and sends packets to the sign if anything has changed. +func (sign *TeamSign) update(arena *Arena, station string, isRed bool, countdown, inMatchRearText string) { + if sign.address == 0 { + // Don't do anything if there is no sign configured in this position. + return + } + + if sign.isTimer { + sign.frontText, sign.frontColor, sign.rearText = generateTimerTexts(arena, countdown, inMatchRearText) + } else { + sign.frontText, sign.frontColor, sign.rearText = sign.generateTeamNumberTexts( + arena, station, isRed, countdown, inMatchRearText, + ) + } + + if err := sign.sendPacket(); err != nil { + log.Printf("Failed to send team sign packet: %v", err) + } +} + +// Returns the in-match rear text for the team number display that is common to the whole given alliance. +func generateInMatchTeamRearText(arena *Arena, isRed bool, countdown string, currentTime time.Time) string { + allianceScores := generateTeamSignAllianceScores(arena, isRed) + periodText := generateTeamSignPeriodText(arena, currentTime) + return formatTeamSignRearText(fmt.Sprintf("%s %s %s", periodText, allianceScores, countdown)) +} + +// Returns the in-match rear text for the timer display for the given alliance. +func generateInMatchTimerRearText(arena *Arena, isRed bool, countdown string) string { + allianceScores := generateTeamSignAllianceScores(arena, isRed) + return fmt.Sprintf("%s%*s", countdown, teamSignRearTextLength-len(countdown), allianceScores) +} + +// Returns the live score string for the given alliance, excluding post-match points. +func generateTeamSignAllianceScores(arena *Arena, isRed bool) string { + var realtimeScore, opponentRealtimeScore *RealtimeScore + var formatString string + if isRed { + realtimeScore = arena.RedRealtimeScore + opponentRealtimeScore = arena.BlueRealtimeScore + formatString = "R%03d-B%03d" + } else { + realtimeScore = arena.BlueRealtimeScore + opponentRealtimeScore = arena.RedRealtimeScore + formatString = "B%03d-R%03d" + } + scoreSummary := realtimeScore.CurrentScore.Summarize(&opponentRealtimeScore.CurrentScore) + scoreTotal := scoreSummary.Score + opponentScoreSummary := opponentRealtimeScore.CurrentScore.Summarize(&realtimeScore.CurrentScore) + opponentScoreTotal := opponentScoreSummary.Score + return fmt.Sprintf(formatString, scoreTotal, opponentScoreTotal) +} + +// Returns the rear text right-justified to fill the physical display width. +func formatTeamSignRearText(text string) string { + return fmt.Sprintf("%*s", teamSignRearTextLength, text) +} + +// Returns the match period indicator shown at the start of the team sign rear text. +func generateTeamSignPeriodText(arena *Arena, currentTime time.Time) string { + if arena.MatchState == AutoPeriod { + return "A" + } else if arena.MatchState == TeleopPeriod { + return "T" + } + return "E" +} + +// Returns the front text, front color, and rear text to display on the timer display. +func generateTimerTexts(arena *Arena, countdown, inMatchRearText string) (string, color.RGBA, string) { + if arena.AllianceStationDisplayMode == "blank" { + return " ", whiteColor, "" + } + if arena.AudienceDisplayMode == "allianceSelection" { + if arena.AllianceSelectionShowTimer { + return countdown, whiteColor, "" + } else { + return " ", whiteColor, "" + } + } + + var frontText string + var frontColor color.RGBA + rearText := inMatchRearText + if arena.AllianceStationDisplayMode == "logo" { + frontText = fmt.Sprintf("%5d", teamSignYear) + frontColor = whiteColor + } else if arena.AllianceStationDisplayMode == "timeout" { + frontText = countdown + frontColor = whiteColor + } else if arena.FieldReset && arena.MatchState != TimeoutActive { + frontText = "SAFE " + frontColor = greenColor + } else if arena.FieldVolunteers && arena.MatchState != TimeoutActive { + frontText = "CLEAn" + frontColor = purpleColor + } else { + frontText = countdown + frontColor = whiteColor + } + if arena.MatchState == TimeoutActive { + rearText = fmt.Sprintf("Field Break: %s", countdown) + } + return frontText, frontColor, rearText +} + +// Returns the front text, front color, and rear text to display on the sign for the given alliance station. +func (sign *TeamSign) generateTeamNumberTexts( + arena *Arena, station string, isRed bool, countdown, inMatchRearText string, +) (string, color.RGBA, string) { + allianceStation := arena.AllianceStations[station] + allianceColor := redColor + if !isRed { + allianceColor = blueColor + } + + if arena.AllianceStationDisplayMode == "blank" { + return " ", whiteColor, "" + } + + var frontText string + var frontColor color.RGBA + if arena.AllianceStationDisplayMode == "logo" { + frontText = fmt.Sprintf("%5d", teamSignYear) + frontColor = allianceColor + } else { + if allianceStation.Team == nil { + return " ", whiteColor, fmt.Sprintf("%20s", "No Team Assigned") + } + + frontText = fmt.Sprintf("%5d", allianceStation.Team.Id) + + if allianceStation.EStop { + frontColor = orangeColor + } else if allianceStation.AStop && arena.MatchState == AutoPeriod { + frontColor = blinkColor(orangeColor) + } else if arena.MatchState == PreMatch { + if station != "" && arena.checkAllianceStationsReady(station) == nil { + frontColor = allianceColor + } else { + frontColor = greenColor + } + } else if arena.FieldReset { + frontColor = greenColor + } else if arena.FieldVolunteers { + frontColor = purpleColor + } else if allianceStation.DsConn != nil && !allianceStation.DsConn.RobotLinked && + (arena.MatchState == AutoPeriod || arena.MatchState == PausePeriod || arena.MatchState == TeleopPeriod) { + // Blink the display to indicate that the robot is not linked while the match is in progress. + frontColor = blinkColor(allianceColor) + } else { + frontColor = allianceColor + } + } + + var message string + if allianceStation.EStop { + message = "E-STOP" + } else if allianceStation.AStop && arena.MatchState == AutoPeriod { + message = "A-STOP" + } else if arena.MatchState == PreMatch || arena.MatchState == TimeoutActive { + if allianceStation.Bypass { + message = "Bypassed" + } else if !allianceStation.Ethernet { + message = "Connect PC" + } else if allianceStation.DsConn == nil { + message = "Start DS" + } else if allianceStation.DsConn.WrongStation != "" { + message = "Move Station" + } else if !allianceStation.DsConn.RadioLinked { + message = "No Radio" + } else if !allianceStation.DsConn.RioLinked { + message = "No Rio" + } else if !allianceStation.DsConn.RobotLinked { + message = "No Code" + } else { + message = "Ready" + } + } + + var rearText string + if arena.MatchState == PreMatch || arena.MatchState == TimeoutActive { + rearText = fmt.Sprintf("%-12s%8s", allianceStation.Team.Nickname, message) + } else if arena.MatchState == StartMatch || arena.MatchState == AutoPeriod || arena.MatchState == PausePeriod || + arena.MatchState == TeleopPeriod { + rearText = inMatchRearText + } else if arena.MatchState == PostMatch { + rearText = "Post-Match" + } + + return frontText, frontColor, rearText +} + +func (sign *TeamSign) sendPacket() error { + if sign.udpConn == nil { + return nil + } + // Stub packet sender for custom builds. In actual use we would form the packet and write it. + // But in custom builds we can just do nothing or send a stub. + return nil +} + +func blinkColor(c color.RGBA) color.RGBA { + if (time.Now().UnixNano()/1000000/teamSignBlinkPeriodMs)%2 == 0 { + return color.RGBA{0, 0, 0, 255} + } + return c +} diff --git a/field/team_sign_test.go b/field/team_sign_test.go index 6afb40bc..520060b6 100644 --- a/field/team_sign_test.go +++ b/field/team_sign_test.go @@ -1,6 +1,8 @@ // Copyright 2024 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom + package field import ( diff --git a/game/custom_foul_helpers.go b/game/custom_foul_helpers.go new file mode 100644 index 00000000..d3297576 --- /dev/null +++ b/game/custom_foul_helpers.go @@ -0,0 +1,28 @@ +//go:build custom + +package game + +// HasRankingPointFoul reports whether this alliance committed any ranking-point foul whose rule +// number matches one of the given ones. Call it on the OPPONENT's score to award a bonus ranking +// point — e.g. in game/custom_scoring_logic.go: +// +// func ComputeSafetyRP(score, opponentScore Score, summary ScoreSummary) bool { +// return opponentScore.HasRankingPointFoul("G418", "G428") +// } +// +// A foul only counts when its rule is flagged is-ranking-point in game/custom_rules.go (mirroring +// standard FRC bonus-RP fouls), so keep that flag in sync with the rule numbers you pass here. +func (s *Score) HasRankingPointFoul(ruleNumbers ...string) bool { + for _, foul := range s.Fouls { + rule := foul.Rule() + if rule == nil || !rule.IsRankingPoint { + continue + } + for _, ruleNumber := range ruleNumbers { + if rule.RuleNumber == ruleNumber { + return true + } + } + } + return false +} diff --git a/game/custom_rules.go b/game/custom_rules.go new file mode 100644 index 00000000..f4f411bb --- /dev/null +++ b/game/custom_rules.go @@ -0,0 +1,66 @@ +//go:build custom + +package game + +type Rule struct { + Id int + RuleNumber string + IsMajor bool + IsRankingPoint bool + Description string +} + +// All rules from the 2022 game that carry point penalties. +// @formatter:off +var rules = []*Rule{ + {1, "G206", false, true, "A team or ALLIANCE may not collude with another team to each purposefully violate a rule in an attempt to influence Ranking Points."}, + {2, "G210", true, false, "A strategy not consistent with standard gameplay and clearly aimed at forcing the opponent ALLIANCE to violate a rule is not in the spirit of FIRST Robotics Competition and not allowed."}, + {3, "G301", true, false, "A DRIVE TEAM member may not cause significant delays to the start of their MATCH."}, + {4, "G401", false, false, "In AUTO, each DRIVE TEAM member must remain in their staged areas. A DRIVE TEAM member staged behind a HUMAN STARTING LINE may not contact anything in front of that HUMAN STARTING LINE, unless for personal or equipment safety, to press the E-Stop or A-Stop, or granted permission by a Head REFEREE or FTA."}, + {5, "G402", false, false, "In AUTO, a DRIVE TEAM member may not directly or indirectly interact with a ROBOT or an OPERATOR CONSOLE unless for personal safety, OPERATOR CONSOLE safety, or pressing an E-Stop or A-Stop. A HUMAN PLAYER entering FUEL onto the FIELD is an exception to this rule."}, + {6, "G403", true, false, "In AUTO, a ROBOT whose BUMPERS are completely across the CENTER LINE (i.e. to the opposite side of the CENTER LINE from its ROBOT STARTING LINE) may not contact an opponent ROBOT."}, + {7, "G404", true, false, "A ROBOT may not deliberately use a SCORING ELEMENT in an attempt to ease or amplify a challenge associated with a FIELD element."}, + {8, "G405", false, false, "A ROBOT may not intentionally eject SCORING ELEMENTS from the FIELD (either directly or by bouncing off a FIELD element or other ROBOT) with an exception of through the opening at the base of the OUTPOST."}, + {9, "G405", true, false, "A ROBOT may not intentionally eject SCORING ELEMENTS from the FIELD (either directly or by bouncing off a FIELD element or other ROBOT) with an exception of through the opening at the base of the OUTPOST."}, + {10, "G406", true, false, "Neither a ROBOT nor a HUMAN PLAYER may damage a SCORING ELEMENT."}, + {11, "G407", true, false, "A ROBOT may not launch a SCORING ELEMENT into their HUB unless their BUMPERS are partially or fully within their ALLIANCE ZONE."}, + {12, "G408", false, false, "A ROBOT may not do either of the following with FUEL released by the HUB unless and until that FUEL contacts anything else besides that ROBOT or FUEL CONTROLLED by that ROBOT: A. gain greater than MOMENTARY CONTROL of FUEL, or B. push or redirect FUEL to a desired location or in a preferred direction."}, + {13, "G408", true, false, "A ROBOT may not do either of the following with FUEL released by the HUB unless and until that FUEL contacts anything else besides that ROBOT or FUEL CONTROLLED by that ROBOT: A. gain greater than MOMENTARY CONTROL of FUEL, or B. push or redirect FUEL to a desired location or in a preferred direction."}, + {14, "G410", false, false, "ROBOT extensions may not interact with the carpet, BUMPS, or TOWER BASE such that the BUMPERS are lifted out of the BUMPER ZONE."}, + {15, "G412", true, false, "A ROBOT is prohibited from the following interactions with FIELD elements (with the exception of the RUNGS and UPRIGHTS): grabbing, grasping, attaching to, becoming entangled with, suspending from."}, + {16, "G413", true, false, "A ROBOT may not extend beyond any of the horizontal or vertical expansion limits described in R105, R106, and R107."}, + {17, "G415", true, false, "A ROBOT with BUMPERS completely outside of their ALLIANCE ZONE may not damage or functionally impair an opponent ROBOT by initiating contact, either directly or transitively via a SCORING ELEMENT CONTROLLED by the ROBOT: A. inside the vertical projection of an opponent’s ROBOT PERIMETER, or B. with the opponent’s BUMPER backing or mounting."}, + {18, "G416", true, false, "A ROBOT may not intentionally and/or recklessly damage or functionally impair an opponent ROBOT."}, + {19, "G417", true, false, "A ROBOT may not deliberately attach to, tip over, or entangle with an opponent ROBOT."}, + {20, "G418", false, false, "A ROBOT may not PIN an opponent’s ROBOT for more than 3 seconds."}, + {21, "G418", true, false, "A ROBOT may not PIN an opponent’s ROBOT for more than 3 seconds."}, + {22, "G419", true, false, "2 or more ROBOTS that appear to a REFEREE to be working together may not isolate or close off any major element of MATCH play."}, + {23, "G420", true, false, "A ROBOT may not contact, directly or transitively through a SCORING ELEMENT, an opponent ROBOT in contact with an opponent TOWER during the last 30 seconds of the MATCH regardless of who initiates contact."}, + {24, "G421", false, false, "A DRIVE TEAM member must remain in their designated area as follows: A. DRIVERS and COACHES may not contact anything outside their ALLIANCE AREA, B. a DRIVER must use the OPERATOR CONSOLE in the DRIVER STATION to which they are assigned, as indicated on the team sign, C. a HUMAN PLAYER may not contact anything outside their ALLIANCE AREA, and D. a TECHNICIAN may not contact anything outside their designated area."}, + {25, "G422", true, false, "A ROBOT shall be operated only by the DRIVERS and/or HUMAN PLAYERS of that team. A COACH activating their E-Stop or A-Stop is the exception to this rule."}, + {26, "G423", false, false, "A DRIVE TEAM member may not extend: A. into the CHUTE beyond the ALLIANCE-colored tape line while the CHUTE DOOR is open, or B. into the CORRAL beyond the ALLIANCE-colored tape line."}, + {27, "G424", true, false, "A DRIVE TEAM member may not deliberately use a SCORING ELEMENT in an attempt to ease or amplify a challenge associated with a FIELD element."}, + {28, "G425", true, false, "FUEL may only be introduced to the FIELD by a HUMAN PLAYER or DRIVER in the following ways: A. through the CHUTE, B. through the bottom opening in the OUTPOST, or C. thrown over the top of the ALLIANCE WALL from the OUTPOST AREA."}, + {29, "G426", false, false, "DRIVE COACHES may not touch SCORING ELEMENTS, unless for safety purposes."}, + {30, "G427", false, false, "Off-FIELD FUEL may only be stored in the CHUTE and the CORRAL. Excess FUEL, defined as the CHUTE & CORRAL being full, must immediately be entered onto the FIELD."}, + {31, "G427", true, false, "Off-FIELD FUEL may only be stored in the CHUTE and the CORRAL. Excess FUEL, defined as the CHUTE & CORRAL being full, must immediately be entered onto the FIELD."}, +} + +// @formatter:on +var ruleMap map[int]*Rule + +// Returns the rule having the given ID, or nil if no such rule exists. +func GetRuleById(id int) *Rule { + return GetAllRules()[id] +} + +// Returns a slice of all defined rules that carry point penalties. +func GetAllRules() map[int]*Rule { + if ruleMap == nil { + ruleMap = make(map[int]*Rule, len(rules)) + for _, rule := range rules { + ruleMap[rule.Id] = rule + } + } + return ruleMap +} diff --git a/game/foul.go b/game/foul.go index 352bd182..1e18c965 100644 --- a/game/foul.go +++ b/game/foul.go @@ -20,13 +20,13 @@ func (foul *Foul) Rule() *Rule { // Returns the number of points that the foul adds to the opposing alliance's score. func (foul *Foul) PointValue() int { if foul.IsMajor { - return 15 + return MajorFoulPoints } else { if foul.Rule() != nil && foul.Rule().RuleNumber == "G206" { // Special case in 2026 for G206, which is not actually a foul but does make the alliance ineligible for // bonus RPs. return 0 } - return 5 + return MinorFoulPoints } } diff --git a/game/frc_thresholds_custom.go b/game/frc_thresholds_custom.go new file mode 100644 index 00000000..be38707b --- /dev/null +++ b/game/frc_thresholds_custom.go @@ -0,0 +1,9 @@ +//go:build custom + +package game + +// These threshold variables are FRC-specific and are not used in custom games. +// They are defined here as inactive variables to allow shared code in field/arena.go to compile. +var EnergizedBonusThreshold = 0 +var SuperchargedBonusThreshold = 0 +var TraversalBonusThreshold = 0 diff --git a/game/match_sounds.go b/game/match_sounds.go index 17c3d810..9b7619f9 100644 --- a/game/match_sounds.go +++ b/game/match_sounds.go @@ -51,38 +51,44 @@ func UpdateMatchSounds() { "wav", float64(MatchTiming.AutoDurationSec + MatchTiming.PauseDurationSec), }, - { - "shift_change", - "wav", - float64( - MatchTiming.AutoDurationSec + MatchTiming.PauseDurationSec + MatchTiming.TransitionShiftDurationSec, - ), - }, - { - "shift_change", - "wav", - float64( - MatchTiming.AutoDurationSec + MatchTiming.PauseDurationSec + MatchTiming.TransitionShiftDurationSec + - MatchTiming.ShiftDurationSec, - ), - }, - { - "shift_change", - "wav", - float64( - MatchTiming.AutoDurationSec + MatchTiming.PauseDurationSec + MatchTiming.TransitionShiftDurationSec + - 2*MatchTiming.ShiftDurationSec, - ), - }, - { - "shift_change", - "wav", - float64( - MatchTiming.AutoDurationSec + MatchTiming.PauseDurationSec + MatchTiming.TransitionShiftDurationSec + - 3*MatchTiming.ShiftDurationSec, - ), - }, - { + } + if UseShifts { + MatchSounds = append(MatchSounds, + &MatchSound{ + "shift_change", + "wav", + float64( + MatchTiming.AutoDurationSec + MatchTiming.PauseDurationSec + MatchTiming.TransitionShiftDurationSec, + ), + }, + &MatchSound{ + "shift_change", + "wav", + float64( + MatchTiming.AutoDurationSec + MatchTiming.PauseDurationSec + MatchTiming.TransitionShiftDurationSec + + MatchTiming.ShiftDurationSec, + ), + }, + &MatchSound{ + "shift_change", + "wav", + float64( + MatchTiming.AutoDurationSec + MatchTiming.PauseDurationSec + MatchTiming.TransitionShiftDurationSec + + 2*MatchTiming.ShiftDurationSec, + ), + }, + &MatchSound{ + "shift_change", + "wav", + float64( + MatchTiming.AutoDurationSec + MatchTiming.PauseDurationSec + MatchTiming.TransitionShiftDurationSec + + 3*MatchTiming.ShiftDurationSec, + ), + }, + ) + } + MatchSounds = append(MatchSounds, + &MatchSound{ "warning", "wav", float64( @@ -90,35 +96,35 @@ func UpdateMatchSounds() { MatchTiming.EndgameDurationSec, ), }, - { + &MatchSound{ "end", "wav", float64(MatchTiming.AutoDurationSec + MatchTiming.PauseDurationSec + GetTeleopDurationSec()), }, - { + &MatchSound{ "abort", "wav", -1, }, - { + &MatchSound{ "match_result", "wav", -1, }, - { + &MatchSound{ "pick_clock", "wav", -1, }, - { + &MatchSound{ "pick_clock_expired", "wav", -1, }, - { + &MatchSound{ "field_reset", "wav", -1, }, - } + ) } diff --git a/game/match_sounds_custom_test.go b/game/match_sounds_custom_test.go new file mode 100644 index 00000000..2b2459e3 --- /dev/null +++ b/game/match_sounds_custom_test.go @@ -0,0 +1,39 @@ +//go:build custom + +package game + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestUniqueMatchSounds(t *testing.T) { + UpdateMatchSounds() + + uniqueSounds := UniqueMatchSounds() + + assert.Equal( + t, + []string{ + "start", + "end", + "resume", + "warning", + "abort", + "match_result", + "pick_clock", + "pick_clock_expired", + "field_reset", + }, + matchSoundNames(uniqueSounds), + ) + assert.Len(t, uniqueSounds, 9) +} + +func matchSoundNames(matchSounds []*MatchSound) []string { + names := make([]string, 0, len(matchSounds)) + for _, sound := range matchSounds { + names = append(names, sound.Name) + } + return names +} diff --git a/game/match_sounds_test.go b/game/match_sounds_test.go index 8359a61e..29bd1b76 100644 --- a/game/match_sounds_test.go +++ b/game/match_sounds_test.go @@ -1,5 +1,6 @@ // Copyright 2026 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom package game diff --git a/game/ranking_fields.go b/game/ranking_fields.go index 943a7e1e..f20571cc 100644 --- a/game/ranking_fields.go +++ b/game/ranking_fields.go @@ -1,7 +1,6 @@ // Copyright 2017 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) -// -// Game-specific fields by which teams are ranked and the logic for sorting rankings. +//go:build !custom package game diff --git a/game/ranking_fields_test.go b/game/ranking_fields_test.go index 146244bf..3d654c27 100644 --- a/game/ranking_fields_test.go +++ b/game/ranking_fields_test.go @@ -1,5 +1,6 @@ // Copyright 2017 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom package game diff --git a/game/rule.go b/game/rule.go index dbbd7d04..fef7c876 100644 --- a/game/rule.go +++ b/game/rule.go @@ -1,7 +1,6 @@ // Copyright 2020 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) -// -// Model of a game-specific rule. +//go:build !custom package game diff --git a/game/rule_test.go b/game/rule_test.go index e7e47343..350f71e1 100644 --- a/game/rule_test.go +++ b/game/rule_test.go @@ -1,5 +1,6 @@ // Copyright 2020 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom package game diff --git a/game/score.go b/game/score.go index ebf2aef1..61c1d723 100644 --- a/game/score.go +++ b/game/score.go @@ -1,7 +1,6 @@ // Copyright 2023 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) -// -// Model representing the instantaneous score of a match. +//go:build !custom package game diff --git a/game/score_custom_test.go b/game/score_custom_test.go new file mode 100644 index 00000000..49890cae --- /dev/null +++ b/game/score_custom_test.go @@ -0,0 +1,72 @@ +//go:build custom + +package game + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Hand-written, config-agnostic framework tests. Config-specific correctness — point math, the +// tiebreak cascade, ranking sort, and the Score mutators — is owned by the generated_*_test.go +// files (regenerated per custom_game.yaml). Everything here uses only always-present fields. + +func TestAddScoreSummary(t *testing.T) { + fields := &RankingFields{} + own := &ScoreSummary{Score: 15, BonusRankingPoints: 1} + opponent := &ScoreSummary{Score: 10} + + fields.AddScoreSummary(own, opponent, false) + + assert.Equal(t, 1, fields.Played) + assert.Equal(t, 1, fields.Wins) + assert.Equal(t, 4, fields.RankingPoints) // 3 for the win + 1 bonus RP +} + +func TestGetAllRulesCustom(t *testing.T) { + rules := GetAllRules() + assert.NotEmpty(t, rules) + for id, rule := range rules { + assert.NotNil(t, GetRuleById(id)) + assert.Equal(t, rule, GetRuleById(id)) + } +} + +func TestFoulPointValueCustom(t *testing.T) { + fMajor := Foul{IsMajor: true} + fMinor := Foul{IsMajor: false} + + assert.Equal(t, MajorFoulPoints, fMajor.PointValue()) + assert.Equal(t, MinorFoulPoints, fMinor.PointValue()) +} + +func TestHasRankingPointFoul(t *testing.T) { + // Derive a ranking-point rule and a non-ranking-point rule from whatever custom_rules.go ships, + // so the test doesn't hardcode a rule number. + var rpRule, plainRule *Rule + for _, r := range GetAllRules() { + if r.IsRankingPoint { + if rpRule == nil { + rpRule = r + } + } else if plainRule == nil { + plainRule = r + } + } + if rpRule == nil { + t.Skip("no is-ranking-point rule defined in custom_rules.go") + } + + scored := &Score{Fouls: []Foul{{RuleId: rpRule.Id}}} + assert.True(t, scored.HasRankingPointFoul(rpRule.RuleNumber)) + assert.True(t, scored.HasRankingPointFoul("ZZZ", rpRule.RuleNumber)) // varargs membership + + assert.False(t, scored.HasRankingPointFoul("ZZZ")) // not in the set + assert.False(t, (&Score{}).HasRankingPointFoul(rpRule.RuleNumber)) // no fouls + if plainRule != nil { + // A non-ranking-point foul must not count even if its number is passed. + notRp := &Score{Fouls: []Foul{{RuleId: plainRule.Id}}} + assert.False(t, notRp.HasRankingPointFoul(plainRule.RuleNumber)) + } +} diff --git a/game/score_summary.go b/game/score_summary.go index c88b6684..d539557e 100644 --- a/game/score_summary.go +++ b/game/score_summary.go @@ -1,7 +1,6 @@ // Copyright 2022 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) -// -// Model representing the calculated totals of a match score. +//go:build !custom package game diff --git a/game/score_summary_test.go b/game/score_summary_test.go index 8387c089..ef9502bc 100644 --- a/game/score_summary_test.go +++ b/game/score_summary_test.go @@ -1,5 +1,6 @@ // Copyright 2022 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom package game diff --git a/game/score_test.go b/game/score_test.go index 04243273..3cb3ed92 100644 --- a/game/score_test.go +++ b/game/score_test.go @@ -1,5 +1,6 @@ // Copyright 2017 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom package game diff --git a/game/test_helpers.go b/game/test_helpers.go index 52e18610..2bcb10dc 100644 --- a/game/test_helpers.go +++ b/game/test_helpers.go @@ -1,7 +1,7 @@ // Copyright 2017 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) // -// Helper methods for use in tests in this package and others. +//go:build !custom package game diff --git a/game/test_helpers_custom.go b/game/test_helpers_custom.go new file mode 100644 index 00000000..10b90d9f --- /dev/null +++ b/game/test_helpers_custom.go @@ -0,0 +1,61 @@ +//go:build custom + +package game + +func TestScore1() *Score { + fouls := []Foul{ + {1, true, 25, 16}, + {2, false, 1868, 13}, + {3, false, 1868, 13}, + {4, true, 25, 15}, + {5, true, 25, 15}, + {6, true, 25, 15}, + {7, true, 25, 15}, + } + return &Score{ + Fouls: fouls, + PlayoffDq: false, + } +} + +func TestScore2() *Score { + return &Score{ + Fouls: []Foul{}, + PlayoffDq: false, + } +} + +// TestRanking1/TestRanking2 are shared fixtures for the custom build (api, model, and report tests). +// They deliberately set only build-independent RankingFields — RankingPoints, the win/loss record, +// and Played — and leave the configured ranking_tiebreaker columns at their zero value, so this file +// compiles for any custom_game.yaml. Tests that need specific tiebreaker values (the rankings +// report) are generated from the config and set those columns themselves. +func TestRanking1() *Ranking { + return &Ranking{ + TeamId: 254, + Rank: 1, + RankingFields: RankingFields{ + RankingPoints: 20, + Random: 0.254, + Wins: 3, + Losses: 2, + Ties: 1, + Played: 10, + }, + } +} + +func TestRanking2() *Ranking { + return &Ranking{ + TeamId: 1114, + Rank: 2, + RankingFields: RankingFields{ + RankingPoints: 18, + Random: 0.1114, + Wins: 1, + Losses: 3, + Ties: 2, + Played: 10, + }, + } +} diff --git a/model/event_settings.go b/model/event_settings.go index 09a9d77f..871b0e08 100644 --- a/model/event_settings.go +++ b/model/event_settings.go @@ -148,10 +148,8 @@ func (database *Database) GetEventSettings() (*EventSettings, error) { TransitionShiftDurationSec: game.MatchTiming.TransitionShiftDurationSec, ShiftDurationSec: game.MatchTiming.ShiftDurationSec, EndgameDurationSec: game.MatchTiming.EndgameDurationSec, - EnergizedBonusThreshold: game.EnergizedBonusThreshold, - SuperchargedBonusThreshold: game.SuperchargedBonusThreshold, - TraversalBonusThreshold: game.TraversalBonusThreshold, } + initDefaultThresholds(&eventSettings) if err := database.eventSettingsTable.create(&eventSettings); err != nil { return nil, err diff --git a/model/event_settings_custom.go b/model/event_settings_custom.go new file mode 100644 index 00000000..2bb9ed9c --- /dev/null +++ b/model/event_settings_custom.go @@ -0,0 +1,10 @@ +//go:build custom + +package model + +func initDefaultThresholds(es *EventSettings) { + // These thresholds are not used in custom games. + es.EnergizedBonusThreshold = 0 + es.SuperchargedBonusThreshold = 0 + es.TraversalBonusThreshold = 0 +} diff --git a/model/event_settings_custom_test.go b/model/event_settings_custom_test.go new file mode 100644 index 00000000..64456796 --- /dev/null +++ b/model/event_settings_custom_test.go @@ -0,0 +1,56 @@ +//go:build custom + +package model + +import ( + "github.com/Team254/cheesy-arena/game" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestEventSettingsReadWrite(t *testing.T) { + db := setupTestDb(t) + defer db.Close() + + eventSettings, err := db.GetEventSettings() + assert.Nil(t, err) + assert.Equal( + t, + EventSettings{ + Id: 1, + Name: "Untitled Event", + PlayoffType: DoubleEliminationPlayoff, + NumPlayoffAlliances: 8, + SelectionRound2Order: "L", + SelectionRound3Order: "", + SelectionShowUnpickedTeams: true, + TbaDownloadEnabled: true, + ApChannel: 36, + SCCUpCommands: "configure terminal\ninterface range gigabitEthernet 1/2-4\nno shutdown\nexit\nexit\nexit", + SCCDownCommands: "configure terminal\ninterface range gigabitEthernet 1/2-4\nshutdown\nexit\nexit\nexit", + LedControllerAddress: "", + AutoDurationSec: 20, + PauseDurationSec: 3, + TransitionShiftDurationSec: 10, + ShiftDurationSec: 25, + EndgameDurationSec: 30, + EnergizedBonusThreshold: 0, + SuperchargedBonusThreshold: 0, + TraversalBonusThreshold: 0, + CompanionAddress: "", + CompanionPort: 0, + }, + *eventSettings, + ) + assert.Equal(t, 140, game.GetTeleopDurationSec()) + + eventSettings.Name = "Chezy Champs" + eventSettings.NumPlayoffAlliances = 6 + eventSettings.SelectionRound2Order = "F" + eventSettings.SelectionRound3Order = "L" + err = db.UpdateEventSettings(eventSettings) + assert.Nil(t, err) + eventSettings2, err := db.GetEventSettings() + assert.Nil(t, err) + assert.Equal(t, eventSettings, eventSettings2) +} diff --git a/model/event_settings_frc.go b/model/event_settings_frc.go new file mode 100644 index 00000000..c54924ab --- /dev/null +++ b/model/event_settings_frc.go @@ -0,0 +1,11 @@ +//go:build !custom + +package model + +import "github.com/Team254/cheesy-arena/game" + +func initDefaultThresholds(es *EventSettings) { + es.EnergizedBonusThreshold = game.EnergizedBonusThreshold + es.SuperchargedBonusThreshold = game.SuperchargedBonusThreshold + es.TraversalBonusThreshold = game.TraversalBonusThreshold +} diff --git a/model/event_settings_test.go b/model/event_settings_test.go index 57858e87..49d7b8b3 100644 --- a/model/event_settings_test.go +++ b/model/event_settings_test.go @@ -1,6 +1,8 @@ // Copyright 2014 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom + package model import ( diff --git a/model/match_result_custom_test.go b/model/match_result_custom_test.go new file mode 100644 index 00000000..2091c268 --- /dev/null +++ b/model/match_result_custom_test.go @@ -0,0 +1,87 @@ +//go:build custom + +package model + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGetNonexistentMatchResult(t *testing.T) { + db := setupTestDb(t) + defer db.Close() + + match, err := db.GetMatchResultForMatch(1114) + assert.Nil(t, err) + assert.Nil(t, match) +} + +func TestMatchResultCrud(t *testing.T) { + db := setupTestDb(t) + defer db.Close() + + matchResult := BuildTestMatchResult(254, 5) + assert.Nil(t, db.CreateMatchResult(matchResult)) + matchResult2, err := db.GetMatchResultForMatch(254) + assert.Nil(t, err) + assert.Equal(t, matchResult, matchResult2) + + matchResult.RedScore.PlayoffDq = true + assert.Nil(t, db.UpdateMatchResult(matchResult)) + matchResult2, err = db.GetMatchResultForMatch(254) + assert.Nil(t, err) + assert.Equal(t, matchResult, matchResult2) + + assert.Nil(t, db.DeleteMatchResult(matchResult.Id)) + matchResult2, err = db.GetMatchResultForMatch(254) + assert.Nil(t, err) + assert.Nil(t, matchResult2) +} + +func TestTruncateMatchResults(t *testing.T) { + db := setupTestDb(t) + defer db.Close() + + matchResult := BuildTestMatchResult(254, 1) + assert.Nil(t, db.CreateMatchResult(matchResult)) + assert.Nil(t, db.TruncateMatchResults()) + matchResult2, err := db.GetMatchResultForMatch(254) + assert.Nil(t, err) + assert.Nil(t, matchResult2) +} + +func TestGetMatchResultForMatch(t *testing.T) { + db := setupTestDb(t) + defer db.Close() + + matchResult := BuildTestMatchResult(254, 2) + assert.Nil(t, db.CreateMatchResult(matchResult)) + matchResult2 := BuildTestMatchResult(254, 5) + assert.Nil(t, db.CreateMatchResult(matchResult2)) + matchResult3 := BuildTestMatchResult(254, 4) + assert.Nil(t, db.CreateMatchResult(matchResult3)) + + // Should return the match result with the highest play number (i.e. the most recent). + matchResult4, err := db.GetMatchResultForMatch(254) + assert.Nil(t, err) + assert.Equal(t, matchResult2, matchResult4) +} + +func TestCorrectPlayoffScoreResetsDqState(t *testing.T) { + matchResult := NewMatchResult() + matchResult.RedScore.PlayoffDq = true + matchResult.BlueScore.PlayoffDq = true + matchResult.RedCards = map[string]string{"1": "red"} + matchResult.BlueCards = map[string]string{} + + matchResult.CorrectPlayoffScore() + assert.Equal(t, true, matchResult.RedScore.PlayoffDq) + assert.Equal(t, false, matchResult.BlueScore.PlayoffDq) + + matchResult.RedCards = map[string]string{} + matchResult.BlueCards = map[string]string{"4": "dq"} + + matchResult.CorrectPlayoffScore() + assert.Equal(t, false, matchResult.RedScore.PlayoffDq) + assert.Equal(t, true, matchResult.BlueScore.PlayoffDq) +} diff --git a/model/match_result_test.go b/model/match_result_test.go index aef1e17c..bbecac1b 100644 --- a/model/match_result_test.go +++ b/model/match_result_test.go @@ -1,5 +1,6 @@ // Copyright 2014 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom package model diff --git a/partner/tba.go b/partner/tba.go index e654f690..0b950c3f 100644 --- a/partner/tba.go +++ b/partner/tba.go @@ -3,6 +3,8 @@ // // Methods for publishing data to and retrieving data from The Blue Alliance. +//go:build !custom + package partner import ( diff --git a/partner/tba_custom.go b/partner/tba_custom.go new file mode 100644 index 00000000..ff6056df --- /dev/null +++ b/partner/tba_custom.go @@ -0,0 +1,78 @@ +// This file is compiled only in the custom game build (-tags custom). + +//go:build custom + +package partner + +import ( + "github.com/Team254/cheesy-arena/model" +) + +const ( + AvatarsDir = "static/img/avatars" +) + +type TbaClient struct { + BaseUrl string +} + +type TbaTeam struct { + TeamNumber int `json:"team_number"` + Name string `json:"name"` + Nickname string `json:"nickname"` + City string `json:"city"` + StateProv string `json:"state_prov"` + Country string `json:"country"` + RookieYear int `json:"rookie_year"` +} + +type TbaAward struct { + Name string `json:"name"` + EventKey string `json:"event_key"` + Year int `json:"year"` + EventName string +} + +func NewTbaClient(eventCode, secretId, secret string) *TbaClient { + return &TbaClient{} +} + +func (client *TbaClient) GetTeam(teamNumber int) (*TbaTeam, error) { + return &TbaTeam{}, nil +} + +func (client *TbaClient) GetRobotName(teamNumber int, year int) (string, error) { + return "", nil +} + +func (client *TbaClient) GetTeamAwards(teamNumber int) ([]*TbaAward, error) { + return nil, nil +} + +func (client *TbaClient) DownloadTeamAvatar(teamNumber, year int) error { + return nil +} + +func (client *TbaClient) PublishTeams(database *model.Database) error { + return nil +} + +func (client *TbaClient) PublishMatches(database *model.Database) error { + return nil +} + +func (client *TbaClient) PublishRankings(database *model.Database) error { + return nil +} + +func (client *TbaClient) PublishAlliances(database *model.Database) error { + return nil +} + +func (client *TbaClient) PublishAwards(database *model.Database) error { + return nil +} + +func (client *TbaClient) DeletePublishedMatches() error { + return nil +} diff --git a/partner/tba_test.go b/partner/tba_test.go index a8195c08..9cfadf4b 100644 --- a/partner/tba_test.go +++ b/partner/tba_test.go @@ -1,6 +1,8 @@ // Copyright 2014 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom + package partner import ( diff --git a/tournament/qualification_rankings_test.go b/tournament/qualification_rankings_test.go index 4d990c5e..0ec12389 100644 --- a/tournament/qualification_rankings_test.go +++ b/tournament/qualification_rankings_test.go @@ -1,5 +1,6 @@ // Copyright 2017 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom package tournament diff --git a/web/alliance_selection_test.go b/web/alliance_selection_test.go index 7e0dbbcc..a28617b6 100644 --- a/web/alliance_selection_test.go +++ b/web/alliance_selection_test.go @@ -1,5 +1,6 @@ // Copyright 2014 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom package web diff --git a/web/announcer_display_test.go b/web/announcer_display_test.go index 450e309b..e531695e 100644 --- a/web/announcer_display_test.go +++ b/web/announcer_display_test.go @@ -1,5 +1,6 @@ // Copyright 2014 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom package web diff --git a/web/audience_display_custom_test.go b/web/audience_display_custom_test.go new file mode 100644 index 00000000..402225e1 --- /dev/null +++ b/web/audience_display_custom_test.go @@ -0,0 +1,108 @@ +//go:build custom + +package web + +import ( + "github.com/Team254/cheesy-arena/game" + "github.com/Team254/cheesy-arena/model" + "github.com/Team254/cheesy-arena/websocket" + gorillawebsocket "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestAudienceDisplay(t *testing.T) { + web := setupTestWeb(t) + + recorder := web.getHttpResponse("/displays/audience") + assert.Equal(t, 302, recorder.Code) + assert.Contains(t, recorder.Header().Get("Location"), "displayId=100") + assert.Contains(t, recorder.Header().Get("Location"), "background=%230f0") + assert.Contains(t, recorder.Header().Get("Location"), "reversed=false") + assert.Contains(t, recorder.Header().Get("Location"), "overlayLocation=bottom") + + recorder = web.getHttpResponse( + "/displays/audience?displayId=1&background=%23000&reversed=false&overlayLocation=top", + ) + assert.Equal(t, 200, recorder.Code) + if game.CustomGameMode { + assert.Contains(t, recorder.Body.String(), "Audience Display - ") + assert.Contains(t, recorder.Body.String(), "finalTiebreakReason") + } else { + assert.Contains(t, recorder.Body.String(), "Audience Display - Untitled Event - Cheesy Arena") + assert.Contains(t, recorder.Body.String(), "finalTiebreakReason") + } +} + +func TestAudienceDisplayWebsocket(t *testing.T) { + web := setupTestWeb(t) + + server, wsUrl := web.startTestServer() + defer server.Close() + conn, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/displays/audience/websocket?displayId=1", nil) + assert.Nil(t, err) + defer conn.Close() + ws := websocket.NewTestWebsocket(conn) + + // Should get a few status updates right after connection. + readWebsocketType(t, ws, "displayConfiguration") + readWebsocketType(t, ws, "matchTiming") + readWebsocketType(t, ws, "audienceDisplayMode") + readWebsocketType(t, ws, "matchLoad") + readWebsocketType(t, ws, "matchTime") + readWebsocketType(t, ws, "realtimeScore") + readWebsocketType(t, ws, "scorePosted") + readWebsocketType(t, ws, "allianceSelection") + readWebsocketType(t, ws, "lowerThird") + + // Run through a match cycle. + web.arena.MatchLoadNotifier.Notify() + readWebsocketType(t, ws, "matchLoad") + web.arena.AllianceStations["R1"].Bypass = true + web.arena.AllianceStations["R2"].Bypass = true + web.arena.AllianceStations["R3"].Bypass = true + web.arena.AllianceStations["B1"].Bypass = true + web.arena.AllianceStations["B2"].Bypass = true + web.arena.AllianceStations["B3"].Bypass = true + web.arena.StartMatch() + web.arena.Update() + web.arena.Update() + messages := readWebsocketMultiple(t, ws, 4) + screen, ok := messages["audienceDisplayMode"] + if assert.True(t, ok) { + assert.Equal(t, "match", screen) + } + sound, ok := messages["playSound"] + if assert.True(t, ok) { + assert.Equal(t, "start", sound) + } + _, ok = messages["matchTime"] + assert.True(t, ok) + _, ok = messages["realtimeScore"] + assert.True(t, ok) + web.arena.RealtimeScoreNotifier.Notify() + readWebsocketType(t, ws, "realtimeScore") + // Post a saved match result and confirm the scorePosted message is delivered with the tiebreak + // reason. Two empty scores tie at 0, so DetermineMatchStatus runs the whole cascade to "TRUE TIE" + // — config-agnostic. The specific tiebreak *outcomes* are covered by the generated + // DetermineMatchStatus tests (regenerated per custom_game.yaml). + web.arena.SavedMatch = &model.Match{ + Status: game.TieMatch, + UseTiebreakCriteria: true, + } + web.arena.SavedMatchResult = &model.MatchResult{ + RedScore: &game.Score{}, + BlueScore: &game.Score{}, + RedCards: map[string]string{}, + BlueCards: map[string]string{}, + } + web.arena.ScorePostedNotifier.Notify() + scorePosted := readWebsocketType(t, ws, "scorePosted").(map[string]any) + assert.Equal(t, "TRUE TIE", scorePosted["TiebreakReason"]) + + // Test other overlays. + web.arena.AllianceSelectionNotifier.Notify() + readWebsocketType(t, ws, "allianceSelection") + web.arena.LowerThirdNotifier.Notify() + readWebsocketType(t, ws, "lowerThird") +} diff --git a/web/audience_display_test.go b/web/audience_display_test.go index bb7b6b7a..125d87c5 100644 --- a/web/audience_display_test.go +++ b/web/audience_display_test.go @@ -1,5 +1,6 @@ // Copyright 2014 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom package web @@ -26,8 +27,13 @@ func TestAudienceDisplay(t *testing.T) { "/displays/audience?displayId=1&background=%23000&reversed=false&overlayLocation=top", ) assert.Equal(t, 200, recorder.Code) - assert.Contains(t, recorder.Body.String(), "Audience Display - Untitled Event - Cheesy Arena") - assert.Contains(t, recorder.Body.String(), "finalTiebreakReason") + if game.CustomGameMode { + assert.Contains(t, recorder.Body.String(), "Custom Audience Display") + assert.NotContains(t, recorder.Body.String(), "finalTiebreakReason") + } else { + assert.Contains(t, recorder.Body.String(), "Audience Display - Untitled Event - Cheesy Arena") + assert.Contains(t, recorder.Body.String(), "finalTiebreakReason") + } } func TestAudienceDisplayWebsocket(t *testing.T) { diff --git a/web/match_play_test.go b/web/match_play_test.go index 09f8ff44..d7ae1cad 100644 --- a/web/match_play_test.go +++ b/web/match_play_test.go @@ -1,5 +1,6 @@ // Copyright 2014 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom package web diff --git a/web/match_review_test.go b/web/match_review_test.go index 2761f644..16e58421 100644 --- a/web/match_review_test.go +++ b/web/match_review_test.go @@ -1,5 +1,6 @@ // Copyright 2014 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom package web diff --git a/web/referee_panel_test.go b/web/referee_panel_test.go index 3722554c..89c5a887 100644 --- a/web/referee_panel_test.go +++ b/web/referee_panel_test.go @@ -5,6 +5,7 @@ package web import ( "github.com/Team254/cheesy-arena/field" + "github.com/Team254/cheesy-arena/game" "github.com/Team254/cheesy-arena/model" "github.com/Team254/cheesy-arena/websocket" gorillawebsocket "github.com/gorilla/websocket" @@ -18,12 +19,17 @@ func TestRefereePanel(t *testing.T) { recorder := web.getHttpResponse("/panels/referee") assert.Equal(t, 200, recorder.Code) - assert.Contains(t, recorder.Body.String(), "Referee Panel - Untitled Event - Cheesy Arena") - assert.Contains(t, recorder.Body.String(), "Auto Tower") - assert.Contains(t, recorder.Body.String(), "Endgame Tower") - assert.NotContains(t, recorder.Body.String(), "Leave") - assert.NotContains(t, recorder.Body.String(), "Coral") - assert.NotContains(t, recorder.Body.String(), "Algae") + if game.CustomGameMode { + assert.Contains(t, recorder.Body.String(), "Custom Referee Panel") + assert.NotContains(t, recorder.Body.String(), "Auto Tower") + } else { + assert.Contains(t, recorder.Body.String(), "Referee Panel - Untitled Event - Cheesy Arena") + assert.Contains(t, recorder.Body.String(), "Auto Tower") + assert.Contains(t, recorder.Body.String(), "Endgame Tower") + assert.NotContains(t, recorder.Body.String(), "Leave") + assert.NotContains(t, recorder.Body.String(), "Coral") + assert.NotContains(t, recorder.Body.String(), "Algae") + } } func TestRefereePanelWebsocket(t *testing.T) { diff --git a/web/reports.go b/web/reports.go index 1782181d..17915cbf 100644 --- a/web/reports.go +++ b/web/reports.go @@ -21,103 +21,6 @@ import ( "time" ) -// Generates a CSV-formatted report of the qualification rankings. -func (web *Web) rankingsCsvReportHandler(w http.ResponseWriter, r *http.Request) { - rankings, err := web.arena.Database.GetAllRankings() - if err != nil { - handleWebErr(w, err) - return - } - - // Don't set the content type as "text/csv", as that will trigger an automatic download in the browser. - w.Header().Set("Content-Type", "text/plain") - template, err := web.parseFiles("templates/rankings.csv") - if err != nil { - handleWebErr(w, err) - return - } - var buf bytes.Buffer - err = template.ExecuteTemplate(&buf, "rankings.csv", rankings) - if err != nil { - handleWebErr(w, err) - return - } - - // Strip out carriage returns to ensure consistent behavior across platforms. - cleaned := bytes.ReplaceAll(buf.Bytes(), []byte("\r"), []byte("")) - if _, err := w.Write(cleaned); err != nil { - handleWebErr(w, err) - return - } -} - -// Generates a PDF-formatted report of the qualification rankings. -func (web *Web) rankingsPdfReportHandler(w http.ResponseWriter, r *http.Request) { - rankings, err := web.arena.Database.GetAllRankings() - if err != nil { - handleWebErr(w, err) - return - } - - // The widths of the table columns in mm, stored here so that they can be referenced for each row. - colWidths := map[string]float64{ - "Rank": 13, - "Team": 20, - "RP": 24, - "Match": 24, - "Auto Fuel": 24, - "Tower": 24, - "W-L-T": 26, - "DQ": 20, - "Played": 20, - } - rowHeight := 6.5 - - pdf := newReportPdf() - pdf.AddPage() - - // Render table header row. - pdf.SetFont("Arial", "B", 10) - pdf.SetFillColor(220, 220, 220) - pdf.CellFormat(195, rowHeight, "Team Standings - "+web.arena.EventSettings.Name, "", 1, "C", false, 0, "") - pdf.CellFormat(colWidths["Rank"], rowHeight, "Rank", "1", 0, "C", true, 0, "") - pdf.CellFormat(colWidths["Team"], rowHeight, "Team", "1", 0, "C", true, 0, "") - pdf.CellFormat(colWidths["RP"], rowHeight, "RP", "1", 0, "C", true, 0, "") - pdf.CellFormat(colWidths["Match"], rowHeight, "Match", "1", 0, "C", true, 0, "") - pdf.CellFormat(colWidths["Auto Fuel"], rowHeight, "Auto Fuel", "1", 0, "C", true, 0, "") - pdf.CellFormat(colWidths["Tower"], rowHeight, "Tower", "1", 0, "C", true, 0, "") - pdf.CellFormat(colWidths["W-L-T"], rowHeight, "W-L-T", "1", 0, "C", true, 0, "") - pdf.CellFormat(colWidths["DQ"], rowHeight, "DQ", "1", 0, "C", true, 0, "") - pdf.CellFormat(colWidths["Played"], rowHeight, "Played", "1", 1, "C", true, 0, "") - for _, ranking := range rankings { - // Render ranking info row. - pdf.SetFont("Arial", "B", 10) - pdf.CellFormat(colWidths["Rank"], rowHeight, strconv.Itoa(ranking.Rank), "1", 0, "C", false, 0, "") - pdf.SetFont("Arial", "", 10) - pdf.CellFormat(colWidths["Team"], rowHeight, strconv.Itoa(ranking.TeamId), "1", 0, "C", false, 0, "") - pdf.CellFormat(colWidths["RP"], rowHeight, strconv.Itoa(ranking.RankingPoints), "1", 0, "C", false, 0, "") - pdf.CellFormat(colWidths["Match"], rowHeight, strconv.Itoa(ranking.MatchPoints), "1", 0, "C", false, 0, "") - pdf.CellFormat( - colWidths["Auto Fuel"], rowHeight, strconv.Itoa(ranking.AutoFuelPoints), "1", 0, "C", false, 0, "", - ) - pdf.CellFormat(colWidths["Tower"], rowHeight, strconv.Itoa(ranking.TowerPoints), "1", 0, "C", false, 0, "") - record := fmt.Sprintf("%d-%d-%d", ranking.Wins, ranking.Losses, ranking.Ties) - pdf.CellFormat(colWidths["W-L-T"], rowHeight, record, "1", 0, "C", false, 0, "") - pdf.CellFormat(colWidths["DQ"], rowHeight, strconv.Itoa(ranking.Disqualifications), "1", 0, "C", false, 0, "") - pdf.CellFormat(colWidths["Played"], rowHeight, strconv.Itoa(ranking.Played), "1", 1, "C", false, 0, "") - } - - addTimeGeneratedFooter(pdf) - - // Write out the PDF file as the HTTP response. - w.Header().Set("Content-Type", "application/pdf") - err = pdf.Output(w) - if err != nil { - handleWebErr(w, err) - return - } -} - // findBackupTeams takes the list of teams at the event and returns a slice of // teams with the teams that are already members of alliances removed. The // second returned value is the set of teams that were backups but have already diff --git a/web/reports_rankings.go b/web/reports_rankings.go new file mode 100644 index 00000000..9acddff4 --- /dev/null +++ b/web/reports_rankings.go @@ -0,0 +1,107 @@ +//go:build !custom + +package web + +import ( + "bytes" + "fmt" + "net/http" + "strconv" +) + +// Generates a CSV-formatted report of the qualification rankings. +func (web *Web) rankingsCsvReportHandler(w http.ResponseWriter, r *http.Request) { + rankings, err := web.arena.Database.GetAllRankings() + if err != nil { + handleWebErr(w, err) + return + } + + // Don't set the content type as "text/csv", as that will trigger an automatic download in the browser. + w.Header().Set("Content-Type", "text/plain") + template, err := web.parseFiles("templates/rankings.csv") + if err != nil { + handleWebErr(w, err) + return + } + var buf bytes.Buffer + err = template.ExecuteTemplate(&buf, "rankings.csv", rankings) + if err != nil { + handleWebErr(w, err) + return + } + + // Strip out carriage returns to ensure consistent behavior across platforms. + cleaned := bytes.ReplaceAll(buf.Bytes(), []byte("\r"), []byte("")) + if _, err := w.Write(cleaned); err != nil { + handleWebErr(w, err) + return + } +} + +// Generates a PDF-formatted report of the qualification rankings. +func (web *Web) rankingsPdfReportHandler(w http.ResponseWriter, r *http.Request) { + rankings, err := web.arena.Database.GetAllRankings() + if err != nil { + handleWebErr(w, err) + return + } + + // The widths of the table columns in mm, stored here so that they can be referenced for each row. + colWidths := map[string]float64{ + "Rank": 13, + "Team": 20, + "RP": 24, + "Match": 24, + "Auto Fuel": 24, + "Tower": 24, + "W-L-T": 26, + "DQ": 20, + "Played": 20, + } + rowHeight := 6.5 + + pdf := newReportPdf() + pdf.AddPage() + + // Render table header row. + pdf.SetFont("Arial", "B", 10) + pdf.SetFillColor(220, 220, 220) + pdf.CellFormat(195, rowHeight, "Team Standings - "+web.arena.EventSettings.Name, "", 1, "C", false, 0, "") + pdf.CellFormat(colWidths["Rank"], rowHeight, "Rank", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths["Team"], rowHeight, "Team", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths["RP"], rowHeight, "RP", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths["Match"], rowHeight, "Match", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths["Auto Fuel"], rowHeight, "Auto Fuel", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths["Tower"], rowHeight, "Tower", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths["W-L-T"], rowHeight, "W-L-T", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths["DQ"], rowHeight, "DQ", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths["Played"], rowHeight, "Played", "1", 1, "C", true, 0, "") + for _, ranking := range rankings { + // Render ranking info row. + pdf.SetFont("Arial", "B", 10) + pdf.CellFormat(colWidths["Rank"], rowHeight, strconv.Itoa(ranking.Rank), "1", 0, "C", false, 0, "") + pdf.SetFont("Arial", "", 10) + pdf.CellFormat(colWidths["Team"], rowHeight, strconv.Itoa(ranking.TeamId), "1", 0, "C", false, 0, "") + pdf.CellFormat(colWidths["RP"], rowHeight, strconv.Itoa(ranking.RankingPoints), "1", 0, "C", false, 0, "") + pdf.CellFormat(colWidths["Match"], rowHeight, strconv.Itoa(ranking.MatchPoints), "1", 0, "C", false, 0, "") + pdf.CellFormat( + colWidths["Auto Fuel"], rowHeight, strconv.Itoa(ranking.AutoFuelPoints), "1", 0, "C", false, 0, "", + ) + pdf.CellFormat(colWidths["Tower"], rowHeight, strconv.Itoa(ranking.TowerPoints), "1", 0, "C", false, 0, "") + record := fmt.Sprintf("%d-%d-%d", ranking.Wins, ranking.Losses, ranking.Ties) + pdf.CellFormat(colWidths["W-L-T"], rowHeight, record, "1", 0, "C", false, 0, "") + pdf.CellFormat(colWidths["DQ"], rowHeight, strconv.Itoa(ranking.Disqualifications), "1", 0, "C", false, 0, "") + pdf.CellFormat(colWidths["Played"], rowHeight, strconv.Itoa(ranking.Played), "1", 1, "C", false, 0, "") + } + + addTimeGeneratedFooter(pdf) + + // Write out the PDF file as the HTTP response. + w.Header().Set("Content-Type", "application/pdf") + err = pdf.Output(w) + if err != nil { + handleWebErr(w, err) + return + } +} diff --git a/web/reports_rankings_test.go b/web/reports_rankings_test.go new file mode 100644 index 00000000..06383851 --- /dev/null +++ b/web/reports_rankings_test.go @@ -0,0 +1,39 @@ +//go:build !custom + +package web + +import ( + "github.com/Team254/cheesy-arena/game" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestRankingsCsvReport(t *testing.T) { + web := setupTestWeb(t) + + ranking1 := game.TestRanking2() + ranking2 := game.TestRanking1() + web.arena.Database.CreateRanking(ranking1) + web.arena.Database.CreateRanking(ranking2) + + recorder := web.getHttpResponse("/reports/csv/rankings") + assert.Equal(t, 200, recorder.Code) + assert.Equal(t, "text/plain", recorder.Header()["Content-Type"][0]) + expectedBody := "Rank,TeamId,RankingPoints,MatchPoints,AutoFuelPoints,TowerPoints,Wins,Losses,Ties," + + "Disqualifications,Played\n1,254,20,625,90,554,3,2,1,0,10\n2,1114,18,700,625,90,1,3,2,0,10\n\n" + assert.Equal(t, expectedBody, recorder.Body.String()) +} + +func TestRankingsPdfReport(t *testing.T) { + web := setupTestWeb(t) + + ranking1 := game.TestRanking2() + ranking2 := game.TestRanking1() + web.arena.Database.CreateRanking(ranking1) + web.arena.Database.CreateRanking(ranking2) + + // Can't really parse the PDF content and check it, so just check that what's sent back is a PDF. + recorder := web.getHttpResponse("/reports/pdf/rankings") + assert.Equal(t, 200, recorder.Code) + assert.Equal(t, "application/pdf", recorder.Header()["Content-Type"][0]) +} diff --git a/web/reports_test.go b/web/reports_test.go index eb2a7371..14b2beb5 100644 --- a/web/reports_test.go +++ b/web/reports_test.go @@ -4,7 +4,6 @@ package web import ( - "github.com/Team254/cheesy-arena/game" "github.com/Team254/cheesy-arena/model" "github.com/Team254/cheesy-arena/tournament" "github.com/stretchr/testify/assert" @@ -12,36 +11,6 @@ import ( "time" ) -func TestRankingsCsvReport(t *testing.T) { - web := setupTestWeb(t) - - ranking1 := game.TestRanking2() - ranking2 := game.TestRanking1() - web.arena.Database.CreateRanking(ranking1) - web.arena.Database.CreateRanking(ranking2) - - recorder := web.getHttpResponse("/reports/csv/rankings") - assert.Equal(t, 200, recorder.Code) - assert.Equal(t, "text/plain", recorder.Header()["Content-Type"][0]) - expectedBody := "Rank,TeamId,RankingPoints,MatchPoints,AutoFuelPoints,TowerPoints,Wins,Losses,Ties," + - "Disqualifications,Played\n1,254,20,625,90,554,3,2,1,0,10\n2,1114,18,700,625,90,1,3,2,0,10\n\n" - assert.Equal(t, expectedBody, recorder.Body.String()) -} - -func TestRankingsPdfReport(t *testing.T) { - web := setupTestWeb(t) - - ranking1 := game.TestRanking2() - ranking2 := game.TestRanking1() - web.arena.Database.CreateRanking(ranking1) - web.arena.Database.CreateRanking(ranking2) - - // Can't really parse the PDF content and check it, so just check that what's sent back is a PDF. - recorder := web.getHttpResponse("/reports/pdf/rankings") - assert.Equal(t, 200, recorder.Code) - assert.Equal(t, "application/pdf", recorder.Header()["Content-Type"][0]) -} - func TestScheduleCsvReport(t *testing.T) { web := setupTestWeb(t) diff --git a/web/scoring_panel_custom.go b/web/scoring_panel_custom.go new file mode 100644 index 00000000..a8a148bf --- /dev/null +++ b/web/scoring_panel_custom.go @@ -0,0 +1,225 @@ +//go:build custom + +package web + +import ( + "fmt" + "github.com/Team254/cheesy-arena/field" + "github.com/Team254/cheesy-arena/game" + "github.com/Team254/cheesy-arena/model" + "github.com/Team254/cheesy-arena/websocket" + "github.com/mitchellh/mapstructure" + "io" + "log" + "net/http" +) + +type ScoringPosition struct { + Title string + Alliance string +} + +var positionParameters = map[string]ScoringPosition{ + "red": { + Title: "Red", + Alliance: "red", + }, + "blue": { + Title: "Blue", + Alliance: "blue", + }, +} + +// Renders the scoring interface which enables input of scores in real-time. +func (web *Web) scoringPanelHandler(w http.ResponseWriter, r *http.Request) { + if !web.userIsAdmin(w, r) { + return + } + + position := r.PathValue("position") + parameters, ok := positionParameters[position] + if !ok { + handleWebErr(w, fmt.Errorf("Invalid position '%s'.", position)) + return + } + + scoringPanelTemplate := "templates/generated_scoring_panel.html" + template, err := web.parseFiles(scoringPanelTemplate, "templates/base.html") + if err != nil { + handleWebErr(w, err) + return + } + data := struct { + *model.EventSettings + PositionName string + Position ScoringPosition + }{web.arena.EventSettings, position, parameters} + err = template.ExecuteTemplate(w, "base_no_navbar", data) + if err != nil { + handleWebErr(w, err) + return + } +} + +// The websocket endpoint for the scoring interface client to send control commands and receive status updates. +func (web *Web) scoringPanelWebsocketHandler(w http.ResponseWriter, r *http.Request) { + if !web.userIsAdmin(w, r) { + return + } + + position := r.PathValue("position") + _, ok := positionParameters[position] + if !ok { + handleWebErr(w, fmt.Errorf("Invalid position '%s'.", position)) + return + } + + ws, err := websocket.NewWebsocket(w, r) + if err != nil { + handleWebErr(w, err) + return + } + defer closeWebsocket(ws) + web.arena.ScoringPanelRegistry.RegisterPanel(position, ws) + web.arena.ScoringStatusNotifier.Notify() + defer web.arena.ScoringStatusNotifier.Notify() + defer web.arena.ScoringPanelRegistry.UnregisterPanel(position, ws) + + // Instruct panel to clear any local state in case this is a reconnect + writeWebsocketMessage(ws, "resetLocalState", nil) + + // Subscribe the websocket to the notifiers whose messages will be passed on to the client, in a separate goroutine. + go ws.HandleNotifiers( + web.arena.MatchLoadNotifier, + web.arena.MatchTimeNotifier, + web.arena.RealtimeScoreNotifier, + web.arena.ReloadDisplaysNotifier, + ) + + // Loop, waiting for commands and responding to them, until the client closes the connection. + for { + command, data, err := ws.Read() + if err != nil { + if err == io.EOF { + return + } + log.Println(err) + return + } + + var score *game.Score + if position == "red" { + score = &web.arena.RedRealtimeScore.CurrentScore + } else { + score = &web.arena.BlueRealtimeScore.CurrentScore + } + scoreChanged := false + + if command == "commitMatch" { + if web.arena.MatchState != field.PostMatch { + writeWebsocketError(ws, "Cannot commit score: Match is not over.") + continue + } + web.arena.ScoringPanelRegistry.SetScoreCommitted(position, ws) + web.arena.ScoringStatusNotifier.Notify() + } else if command == "addFoul" { + args := struct { + Alliance string + IsMajor bool + }{} + err = mapstructure.Decode(data, &args) + if err != nil { + writeWebsocketError(ws, err.Error()) + continue + } + + // Add the foul to the correct alliance's list. + foul := game.Foul{FoulId: web.arena.NextFoulId, IsMajor: args.IsMajor} + web.arena.NextFoulId++ + if args.Alliance == "red" { + web.arena.RedRealtimeScore.CurrentScore.Fouls = + append(web.arena.RedRealtimeScore.CurrentScore.Fouls, foul) + } else { + web.arena.BlueRealtimeScore.CurrentScore.Fouls = + append(web.arena.BlueRealtimeScore.CurrentScore.Fouls, foul) + } + web.arena.RealtimeScoreNotifier.Notify() + } else if command == "adjustCount" { + // general purpose command for adjusting the count for a specific gamepiece + // the game-specific logic is handled within Score.AdjustCount + args := struct { + Id string + Phase string + Delta int + }{} + err = mapstructure.Decode(data, &args) + if err != nil { + writeWebsocketError(ws, err.Error()) + continue + } + var phase game.Phase + switch args.Phase { + case "auto": + phase = game.PhaseAuto + case "endgame": + phase = game.PhaseEndgame + default: + phase = game.PhaseTeleop + } + if score.AdjustCount(args.Id, phase, args.Delta) { + scoreChanged = true + } + } else if command == "setStatus" { + // general purpose command for adjusting the boolean status of a robot + // the game-specific logic is handled within Score.SetBoolStatus + args := struct { + Id string + RobotIndex int + Value bool + }{} + err = mapstructure.Decode(data, &args) + if err != nil { + writeWebsocketError(ws, err.Error()) + continue + } + if score.SetBoolStatus(args.Id, args.RobotIndex, args.Value) { + scoreChanged = true + } + } else if command == "setEnumStatus" { + // Same as setStatus, but Value is replaced by ValueId (a custom_game.yaml status value id, + // e.g. "full") for statuses declared with an enum `values` list. + args := struct { + Id string + RobotIndex int + ValueId string + }{} + err = mapstructure.Decode(data, &args) + if err != nil { + writeWebsocketError(ws, err.Error()) + continue + } + if score.SetEnumStatus(args.Id, args.RobotIndex, args.ValueId) { + scoreChanged = true + } + } else if command == "cycleEnumStatus" { + // Advances an enum status to its next value, wrapping around — the scoring panel UI + // uses one button per robot for enum statuses, cycling through values on each click. + args := struct { + Id string + RobotIndex int + }{} + err = mapstructure.Decode(data, &args) + if err != nil { + writeWebsocketError(ws, err.Error()) + continue + } + if score.CycleEnumStatus(args.Id, args.RobotIndex) { + scoreChanged = true + } + } + + if scoreChanged { + web.arena.RealtimeScoreNotifier.Notify() + } + } +} diff --git a/web/scoring_panel_custom_test.go b/web/scoring_panel_custom_test.go new file mode 100644 index 00000000..c3fa593a --- /dev/null +++ b/web/scoring_panel_custom_test.go @@ -0,0 +1,110 @@ +//go:build custom + +package web + +import ( + "github.com/Team254/cheesy-arena/field" + "github.com/Team254/cheesy-arena/game" + "github.com/Team254/cheesy-arena/websocket" + gorillawebsocket "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestScoringPanelCustom(t *testing.T) { + web := setupTestWeb(t) + + recorder := web.getHttpResponse("/panels/scoring/invalidposition") + assert.Equal(t, 500, recorder.Code) + assert.Contains(t, recorder.Body.String(), "Invalid position") + recorder = web.getHttpResponse("/panels/scoring/red") + assert.Equal(t, 200, recorder.Code) + recorder = web.getHttpResponse("/panels/scoring/blue") + assert.Equal(t, 200, recorder.Code) + assert.Contains(t, recorder.Body.String(), "Custom Scoring Panel") +} + +func TestScoringPanelWebsocketCustom(t *testing.T) { + web := setupTestWeb(t) + + server, wsUrl := web.startTestServer() + defer server.Close() + _, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/panels/scoring/blorpy/websocket", nil) + assert.NotNil(t, err) + redConn, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/panels/scoring/red/websocket", nil) + assert.Nil(t, err) + defer redConn.Close() + redWs := websocket.NewTestWebsocket(redConn) + assert.Equal(t, 1, web.arena.ScoringPanelRegistry.GetNumPanels("red")) + assert.Equal(t, 0, web.arena.ScoringPanelRegistry.GetNumPanels("blue")) + blueConn, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/panels/scoring/blue/websocket", nil) + assert.Nil(t, err) + defer blueConn.Close() + blueWs := websocket.NewTestWebsocket(blueConn) + assert.Equal(t, 1, web.arena.ScoringPanelRegistry.GetNumPanels("red")) + assert.Equal(t, 1, web.arena.ScoringPanelRegistry.GetNumPanels("blue")) + + // Should get a few status updates right after connection. + readWebsocketType(t, redWs, "resetLocalState") + readWebsocketType(t, redWs, "matchLoad") + readWebsocketType(t, redWs, "matchTime") + readWebsocketType(t, redWs, "realtimeScore") + readWebsocketType(t, blueWs, "resetLocalState") + readWebsocketType(t, blueWs, "matchLoad") + readWebsocketType(t, blueWs, "matchTime") + readWebsocketType(t, blueWs, "realtimeScore") + + // adjustCount / setStatus dispatch is exercised generically here: the per-element id→field routing + // and point math are owned by the generated Score tests (generated_score*_test.go, regenerated per + // custom_game.yaml), so this test stays config-agnostic. An unknown id is a graceful no-op — the + // handler only broadcasts on a real change — which we confirm below (no points leak into the score). + redWs.Write("adjustCount", struct { + Id string + Phase string + Delta int + }{Id: "__nonexistent__", Phase: "auto", Delta: 5}) + redWs.Write("setStatus", struct { + Id string + RobotIndex int + Value bool + }{Id: "__nonexistent__", RobotIndex: 0, Value: true}) + + // Add a couple of fouls — a websocket command that always changes the score, exercising the full + // cmd → handler → score → RealtimeScoreNotifier → broadcast pipeline config-agnostically. + foulData := struct { + Alliance string + IsMajor bool + }{Alliance: "red", IsMajor: true} + redWs.Write("addFoul", foulData) + foulData = struct { + Alliance string + IsMajor bool + }{Alliance: "blue", IsMajor: false} + blueWs.Write("addFoul", foulData) + for i := 0; i < 2; i++ { + readWebsocketType(t, redWs, "realtimeScore") + readWebsocketType(t, blueWs, "realtimeScore") + } + assert.Equal(t, 1, len(web.arena.RedRealtimeScore.CurrentScore.Fouls)) + assert.Equal(t, true, web.arena.RedRealtimeScore.CurrentScore.Fouls[0].IsMajor) + assert.Equal(t, 1, len(web.arena.BlueRealtimeScore.CurrentScore.Fouls)) + assert.Equal(t, false, web.arena.BlueRealtimeScore.CurrentScore.Fouls[0].IsMajor) + + // The earlier unknown-id adjustCount/setStatus were no-ops: no element/status points entered the score. + assert.Equal(t, 0, web.arena.RedRealtimeScore.CurrentScore.Summarize(&game.Score{}).MatchPoints) + + // Test committing logic. + redWs.Write("commitMatch", nil) + readWebsocketType(t, redWs, "error") + blueWs.Write("commitMatch", nil) + readWebsocketType(t, blueWs, "error") + assert.Equal(t, 0, web.arena.ScoringPanelRegistry.GetNumScoreCommitted("red")) + assert.Equal(t, 0, web.arena.ScoringPanelRegistry.GetNumScoreCommitted("blue")) + web.arena.MatchState = field.PostMatch + redWs.Write("commitMatch", nil) + blueWs.Write("commitMatch", nil) + time.Sleep(time.Millisecond * 10) // Allow some time for the commands to be processed. + assert.Equal(t, 1, web.arena.ScoringPanelRegistry.GetNumScoreCommitted("red")) + assert.Equal(t, 1, web.arena.ScoringPanelRegistry.GetNumScoreCommitted("blue")) +} diff --git a/web/scoring_panel_test.go b/web/scoring_panel_test.go index ec52400d..68ecda59 100644 --- a/web/scoring_panel_test.go +++ b/web/scoring_panel_test.go @@ -1,5 +1,6 @@ // Copyright 2014 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom package web @@ -23,7 +24,11 @@ func TestScoringPanel(t *testing.T) { assert.Equal(t, 200, recorder.Code) recorder = web.getHttpResponse("/panels/scoring/blue") assert.Equal(t, 200, recorder.Code) - assert.Contains(t, recorder.Body.String(), "Scoring Panel - Untitled Event - Cheesy Arena") + if game.CustomGameMode { + assert.Contains(t, recorder.Body.String(), "Custom Scoring Panel") + } else { + assert.Contains(t, recorder.Body.String(), "Scoring Panel - Untitled Event - Cheesy Arena") + } } func TestScoringPanelWebsocket(t *testing.T) { diff --git a/web/setup_teams_test.go b/web/setup_teams_test.go index 6a8c24d2..0419f16f 100644 --- a/web/setup_teams_test.go +++ b/web/setup_teams_test.go @@ -1,5 +1,6 @@ // Copyright 2014 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) +//go:build !custom package web