diff --git a/field/arena.go b/field/arena.go index 828b53e..1404f6c 100644 --- a/field/arena.go +++ b/field/arena.go @@ -65,6 +65,7 @@ type Arena struct { AllianceStationDisplayMode string AllianceSelectionAlliances [][]model.AllianceTeam LowerThird *model.LowerThird + BypassPreMatchScore bool MuteMatchSounds bool matchAborted bool lastRedAllianceReady bool @@ -333,6 +334,7 @@ func (arena *Arena) ResetMatch() error { arena.AllianceStations["B1"].Bypass = false arena.AllianceStations["B2"].Bypass = false arena.AllianceStations["B3"].Bypass = false + arena.BypassPreMatchScore = false arena.MuteMatchSounds = false return nil } @@ -582,6 +584,11 @@ func (arena *Arena) checkCanStartMatch() error { return err } + if !arena.BypassPreMatchScore && (!arena.RedRealtimeScore.CurrentScore.IsValidPreMatch() || + !arena.BlueRealtimeScore.CurrentScore.IsValidPreMatch()) { + return fmt.Errorf("Cannot start match until pre-match scoring is set") + } + if arena.EventSettings.PlcAddress != "" { if !arena.Plc.IsHealthy { return fmt.Errorf("Cannot start match while PLC is not healthy.") diff --git a/field/arena_notifiers.go b/field/arena_notifiers.go index 951f2f3..542cc56 100644 --- a/field/arena_notifiers.go +++ b/field/arena_notifiers.go @@ -41,13 +41,14 @@ type LedModeMessage struct { } type MatchTimeMessage struct { - MatchState int + MatchState MatchTimeSec int } type audienceAllianceScoreFields struct { - Score *game.Score - ScoreSummary *game.ScoreSummary + Score *game.Score + ScoreSummary *game.ScoreSummary + IsPreMatchScoreReady bool } // Instantiates notifiers and configures their message producing methods. @@ -92,10 +93,11 @@ func (arena *Arena) generateArenaStatusMessage() interface{} { AllianceStations map[string]*AllianceStation TeamWifiStatuses map[string]network.TeamWifiStatus MatchState - CanStartMatch bool - PlcIsHealthy bool - FieldEstop bool - }{arena.CurrentMatch.Id, arena.AllianceStations, teamWifiStatuses, arena.MatchState, + BypassPreMatchScore bool + CanStartMatch bool + PlcIsHealthy bool + FieldEstop bool + }{arena.CurrentMatch.Id, arena.AllianceStations, teamWifiStatuses, arena.MatchState, arena.BypassPreMatchScore, arena.checkCanStartMatch() == nil, arena.Plc.IsHealthy, arena.Plc.GetFieldEstop()} } @@ -147,7 +149,7 @@ func (arena *Arena) generateMatchLoadMessage() interface{} { } func (arena *Arena) generateMatchTimeMessage() interface{} { - return MatchTimeMessage{int(arena.MatchState), int(arena.MatchTimeSec())} + return MatchTimeMessage{arena.MatchState, int(arena.MatchTimeSec())} } func (arena *Arena) generateMatchTimingMessage() interface{} { @@ -158,9 +160,11 @@ func (arena *Arena) generateRealtimeScoreMessage() interface{} { fields := struct { Red *audienceAllianceScoreFields Blue *audienceAllianceScoreFields + MatchState }{} fields.Red = getAudienceAllianceScoreFields(arena.RedRealtimeScore, arena.RedScoreSummary()) fields.Blue = getAudienceAllianceScoreFields(arena.BlueRealtimeScore, arena.BlueScoreSummary()) + fields.MatchState = arena.MatchState return &fields } @@ -230,6 +234,7 @@ func getAudienceAllianceScoreFields(allianceScore *RealtimeScore, fields := new(audienceAllianceScoreFields) fields.Score = &allianceScore.CurrentScore fields.ScoreSummary = allianceScoreSummary + fields.IsPreMatchScoreReady = allianceScore.CurrentScore.IsValidPreMatch() return fields } diff --git a/field/arena_test.go b/field/arena_test.go index 85f201f..3b6a6b2 100644 --- a/field/arena_test.go +++ b/field/arena_test.go @@ -52,6 +52,53 @@ func TestAssignTeam(t *testing.T) { } } +func TestArenaCheckCanStartMatch(t *testing.T) { + arena := setupTestArena(t) + + // Check robot state constraints. + err := arena.checkCanStartMatch() + if assert.NotNil(t, err) { + assert.Contains(t, err.Error(), "Cannot start match until all robots are connected or bypassed") + } + arena.AllianceStations["R1"].Bypass = true + arena.AllianceStations["R2"].Bypass = true + arena.AllianceStations["R3"].Bypass = true + arena.AllianceStations["B1"].Bypass = true + arena.AllianceStations["B2"].Bypass = true + err = arena.checkCanStartMatch() + if assert.NotNil(t, err) { + assert.Contains(t, err.Error(), "Cannot start match until all robots are connected or bypassed") + } + arena.AllianceStations["B3"].Bypass = true + err = arena.checkCanStartMatch() + if assert.NotNil(t, err) { + assert.Contains(t, err.Error(), "Cannot start match until pre-match scoring is set") + } + + // Check scoring constraints. + arena.RedRealtimeScore.CurrentScore = *game.TestScoreValidPreMatch() + err = arena.checkCanStartMatch() + if assert.NotNil(t, err) { + assert.Contains(t, err.Error(), "Cannot start match until pre-match scoring is set") + } + arena.BlueRealtimeScore.CurrentScore = *game.TestScoreValidPreMatch() + assert.Nil(t, arena.checkCanStartMatch()) + arena.RedRealtimeScore.CurrentScore = game.Score{} + arena.BlueRealtimeScore.CurrentScore = game.Score{} + assert.NotNil(t, arena.checkCanStartMatch()) + arena.BypassPreMatchScore = true + assert.Nil(t, arena.checkCanStartMatch()) + + // Check PLC constraints. + arena.EventSettings.PlcAddress = "1.2.3.4" + err = arena.checkCanStartMatch() + if assert.NotNil(t, err) { + assert.Contains(t, err.Error(), "Cannot start match while PLC is not healthy") + } + arena.Plc.IsHealthy = true + assert.Nil(t, arena.checkCanStartMatch()) +} + func TestArenaMatchFlow(t *testing.T) { arena := setupTestArena(t) @@ -82,6 +129,7 @@ func TestArenaMatchFlow(t *testing.T) { arena.AllianceStations["B1"].Bypass = true arena.AllianceStations["B2"].Bypass = true arena.AllianceStations["B3"].DsConn.RobotLinked = true + arena.BypassPreMatchScore = true err = arena.StartMatch() assert.Nil(t, err) arena.Update() @@ -180,6 +228,7 @@ func TestArenaStateEnforcement(t *testing.T) { arena.AllianceStations["B1"].Bypass = true arena.AllianceStations["B2"].Bypass = true arena.AllianceStations["B3"].Bypass = true + arena.BypassPreMatchScore = true err := arena.LoadMatch(new(model.Match)) assert.Nil(t, err) @@ -286,6 +335,7 @@ func TestMatchStartRobotLinkEnforcement(t *testing.T) { for _, station := range arena.AllianceStations { station.DsConn.RobotLinked = true } + arena.BypassPreMatchScore = true err = arena.StartMatch() assert.Nil(t, err) arena.MatchState = PreMatch @@ -459,6 +509,7 @@ func TestAstop(t *testing.T) { arena.AllianceStations["B1"].Bypass = true arena.AllianceStations["B2"].Bypass = true arena.AllianceStations["B3"].Bypass = true + arena.BypassPreMatchScore = true err = arena.StartMatch() assert.Nil(t, err) arena.Update() @@ -564,6 +615,7 @@ func TestArenaTimeout(t *testing.T) { arena.AllianceStations["B1"].Bypass = true arena.AllianceStations["B2"].Bypass = true arena.AllianceStations["B3"].Bypass = true + arena.BypassPreMatchScore = true assert.Nil(t, arena.StartMatch()) arena.Update() assert.NotNil(t, arena.StartTimeout(1)) @@ -600,6 +652,7 @@ func TestSaveTeamHasConnected(t *testing.T) { arena.AllianceStations["B2"].DsConn = &DriverStationConnection{TeamId: 105, RobotLinked: true} arena.AllianceStations["B3"].DsConn = &DriverStationConnection{TeamId: 106, RobotLinked: true} arena.AllianceStations["B3"].Team.City = "Sand Hosay" // Change some other field to verify that it isn't saved. + arena.BypassPreMatchScore = true assert.Nil(t, arena.StartMatch()) // Check that the connection status was saved for the teams that just linked for the first time. diff --git a/game/score.go b/game/score.go index 2dfa731..b4e4c34 100644 --- a/game/score.go +++ b/game/score.go @@ -141,6 +141,37 @@ func (score *Score) Equals(other *Score) bool { return true } +// Returns true if the score represents a valid pre-match state. +func (score *Score) IsValidPreMatch() bool { + for i := 0; i < 3; i++ { + // Ensure robot start level is set. + if score.RobotStartLevels[i] == 0 || score.RobotStartLevels[i] > 3 { + return false + } + + // Ensure other robot fields and rocket bays are empty. + if score.SandstormBonuses[i] || score.RobotEndLevels[i] != 0 || score.RocketNearLeftBays[i] != BayEmpty || + score.RocketNearRightBays[i] != BayEmpty || score.RocketFarLeftBays[i] != BayEmpty || + score.RocketFarRightBays[i] != BayEmpty { + return false + } + } + for i := 0; i < 8; i++ { + if i == 3 || i == 4 { + // Ensure cargo ship front bays are empty. + if score.CargoBaysPreMatch[i] != BayEmpty { + return false + } + } else { + // Ensure cargo ship side bays have either a hatch or cargo but not both. + if !(score.CargoBaysPreMatch[i] == BayHatch || score.CargoBaysPreMatch[i] == BayCargo) { + return false + } + } + } + return score.CargoBays == score.CargoBaysPreMatch +} + // Calculates the cargo and hatch panel points for the given rocket half and adds them to the summary. func (summary *ScoreSummary) addRocketHalfPoints(rocketHalf [3]BayStatus) { for _, bayStatus := range rocketHalf { diff --git a/game/score_test.go b/game/score_test.go index b36c1e2..707485d 100644 --- a/game/score_test.go +++ b/game/score_test.go @@ -143,3 +143,61 @@ func TestScoreEquals(t *testing.T) { assert.False(t, score1.Equals(score2)) assert.False(t, score2.Equals(score1)) } + +func TestIsValidPreMatch(t *testing.T) { + score := &Score{} + assert.False(t, score.IsValidPreMatch()) + + score = TestScoreValidPreMatch() + score.RobotStartLevels = [3]int{1, 2, 3} + score.CargoBaysPreMatch = [8]BayStatus{1, 3, 3, 0, 0, 1, 1, 3} + score.CargoBays = score.CargoBaysPreMatch + assert.True(t, score.IsValidPreMatch()) + + score = TestScoreValidPreMatch() + score.RobotStartLevels[0] = 0 + assert.False(t, score.IsValidPreMatch()) + + score = TestScoreValidPreMatch() + score.SandstormBonuses[1] = true + assert.False(t, score.IsValidPreMatch()) + + score = TestScoreValidPreMatch() + score.RobotEndLevels[2] = 3 + assert.False(t, score.IsValidPreMatch()) + + score = TestScoreValidPreMatch() + score.CargoBaysPreMatch[0] = BayEmpty + assert.False(t, score.IsValidPreMatch()) + + score = TestScoreValidPreMatch() + score.CargoBaysPreMatch[1] = BayHatchCargo + assert.False(t, score.IsValidPreMatch()) + + score = TestScoreValidPreMatch() + score.CargoBaysPreMatch[3] = BayHatch + score.CargoBaysPreMatch[4] = BayCargo + score.CargoBaysPreMatch[5] = BayEmpty + score.CargoBaysPreMatch[6] = BayEmpty + assert.False(t, score.IsValidPreMatch()) + + score = TestScoreValidPreMatch() + score.CargoBays[0] = BayCargo + assert.False(t, score.IsValidPreMatch()) + + score = TestScoreValidPreMatch() + score.RocketNearLeftBays[0] = BayHatch + assert.False(t, score.IsValidPreMatch()) + + score = TestScoreValidPreMatch() + score.RocketNearRightBays[1] = BayHatchCargo + assert.False(t, score.IsValidPreMatch()) + + score = TestScoreValidPreMatch() + score.RocketFarLeftBays[2] = BayCargo + assert.False(t, score.IsValidPreMatch()) + + score = TestScoreValidPreMatch() + score.RocketFarRightBays[0] = BayHatchCargo + assert.False(t, score.IsValidPreMatch()) +} diff --git a/game/test_helpers.go b/game/test_helpers.go index 225284e..aa474bf 100644 --- a/game/test_helpers.go +++ b/game/test_helpers.go @@ -46,6 +46,14 @@ func TestScore2() *Score { } } +func TestScoreValidPreMatch() *Score { + return &Score{ + RobotStartLevels: [3]int{1, 2, 3}, + CargoBaysPreMatch: [8]BayStatus{1, 3, 3, 0, 0, 1, 1, 3}, + CargoBays: [8]BayStatus{1, 3, 3, 0, 0, 1, 1, 3}, + } +} + func TestRanking1() *Ranking { return &Ranking{254, 1, RankingFields{20, 625, 90, 554, 10, 0.254, 3, 2, 1, 0, 10}} } diff --git a/static/js/match_play.js b/static/js/match_play.js index 110e3bf..3b4ed6e 100644 --- a/static/js/match_play.js +++ b/static/js/match_play.js @@ -18,6 +18,11 @@ var toggleBypass = function(station) { websocket.send("toggleBypass", station); }; +// Sends a websocket message to toggle the bypass state for the pre-match scoring. +var toggleBypassPreMatchScore = function() { + websocket.send("toggleBypassPreMatchScore"); +}; + // Sends a websocket message to start the match. var startMatch = function() { websocket.send("startMatch", @@ -179,6 +184,8 @@ var handleArenaStatus = function(data) { break; } + $("#bypassPreMatchScore").prop("checked", data.BypassPreMatchScore); + if (data.PlcIsHealthy) { $("#plcStatus").text("Connected"); $("#plcStatus").attr("data-ready", true); @@ -201,6 +208,10 @@ var handleMatchTime = function(data) { var handleRealtimeScore = function(data) { $("#redScore").text(data.Red.ScoreSummary.Score); $("#blueScore").text(data.Blue.ScoreSummary.Score); + if (matchStates[data.MatchState] == "PRE_MATCH") { + $("#redPreMatchScoreStatus").attr("data-ready", data.Red.IsPreMatchScoreReady); + $("#bluePreMatchScoreStatus").attr("data-ready", data.Blue.IsPreMatchScoreReady); + } }; // Handles a websocket message to update the audience display screen selector. diff --git a/static/js/scoring_panel.js b/static/js/scoring_panel.js index ce39787..0a65c03 100644 --- a/static/js/scoring_panel.js +++ b/static/js/scoring_panel.js @@ -22,12 +22,13 @@ var handleMatchLoad = function(data) { // Handles a websocket message to update the realtime scoring fields. var handleRealtimeScore = function(data) { - var score; + var realtimeScore; if (alliance === "red") { - score = data.Red.Score; + realtimeScore = data.Red; } else { - score = data.Blue.Score; + realtimeScore = data.Blue; } + var score = realtimeScore.Score; for (var i = 0; i < 3; i++) { var i1 = i + 1; @@ -45,13 +46,21 @@ var handleRealtimeScore = function(data) { for (var i = 0; i < 8; i++) { getBay("cargoShip", i).attr("data-value", score.CargoBays[i]); } + + if (matchStates[data.MatchState] === "PRE_MATCH") { + if (realtimeScore.IsPreMatchScoreReady) { + $("#preMatchMessage").hide(); + } else { + $("#preMatchMessage").css("display", "flex"); + } + } }; // Handles a websocket message to update the match status. var handleMatchTime = function(data) { switch (matchStates[data.MatchState]) { case "PRE_MATCH": - $("#preMatchMessage").css("display", "flex"); + // Pre-match message state is set in handleRealtimeScore(). $("#postMatchMessage").hide(); $("#commitMatchScore").hide(); break; diff --git a/templates/match_play.html b/templates/match_play.html index c512adb..54a26c1 100644 --- a/templates/match_play.html +++ b/templates/match_play.html @@ -112,11 +112,18 @@
Scoring Status
+Pre-Match Scoring
+Red Scoring
+ Blue Scoring
Post-Match Scoring
Referee
PLC Status
diff --git a/web/alliance_station_display_test.go b/web/alliance_station_display_test.go index 977406f..c3078f3 100644 --- a/web/alliance_station_display_test.go +++ b/web/alliance_station_display_test.go @@ -58,6 +58,7 @@ func TestAllianceStationDisplayWebsocket(t *testing.T) { web.arena.AllianceStations["B1"].Bypass = true web.arena.AllianceStations["B2"].Bypass = true web.arena.AllianceStations["B3"].Bypass = true + web.arena.BypassPreMatchScore = true web.arena.StartMatch() web.arena.Update() messages := readWebsocketMultiple(t, ws, 3) diff --git a/web/announcer_display_test.go b/web/announcer_display_test.go index a836548..cca4f82 100644 --- a/web/announcer_display_test.go +++ b/web/announcer_display_test.go @@ -45,6 +45,7 @@ func TestAnnouncerDisplayWebsocket(t *testing.T) { web.arena.AllianceStations["B1"].Bypass = true web.arena.AllianceStations["B2"].Bypass = true web.arena.AllianceStations["B3"].Bypass = true + web.arena.BypassPreMatchScore = true web.arena.StartMatch() web.arena.Update() messages := readWebsocketMultiple(t, ws, 2) diff --git a/web/audience_display_test.go b/web/audience_display_test.go index 72d468e..9a18bba 100644 --- a/web/audience_display_test.go +++ b/web/audience_display_test.go @@ -55,6 +55,7 @@ func TestAudienceDisplayWebsocket(t *testing.T) { web.arena.AllianceStations["B1"].Bypass = true web.arena.AllianceStations["B2"].Bypass = true web.arena.AllianceStations["B3"].Bypass = true + web.arena.BypassPreMatchScore = true web.arena.StartMatch() web.arena.Update() web.arena.Update() diff --git a/web/match_play.go b/web/match_play.go index bd35feb..0414752 100644 --- a/web/match_play.go +++ b/web/match_play.go @@ -207,6 +207,8 @@ func (web *Web) matchPlayWebsocketHandler(w http.ResponseWriter, r *http.Request continue } web.arena.AllianceStations[station].Bypass = !web.arena.AllianceStations[station].Bypass + case "toggleBypassPreMatchScore": + web.arena.BypassPreMatchScore = !web.arena.BypassPreMatchScore case "startMatch": args := struct { MuteMatchSounds bool diff --git a/web/match_play_test.go b/web/match_play_test.go index c1795ce..301cc98 100644 --- a/web/match_play_test.go +++ b/web/match_play_test.go @@ -294,6 +294,7 @@ func TestMatchPlayWebsocketCommands(t *testing.T) { web.arena.AllianceStations["B1"].Bypass = true web.arena.AllianceStations["B2"].Bypass = true web.arena.AllianceStations["B3"].Bypass = true + web.arena.BypassPreMatchScore = true ws.Write("startMatch", nil) readWebsocketType(t, ws, "arenaStatus") assert.Equal(t, field.StartMatch, web.arena.MatchState) @@ -353,6 +354,7 @@ func TestMatchPlayWebsocketNotifications(t *testing.T) { web.arena.AllianceStations["B1"].Bypass = true web.arena.AllianceStations["B2"].Bypass = true web.arena.AllianceStations["B3"].Bypass = true + web.arena.BypassPreMatchScore = true assert.Nil(t, web.arena.StartMatch()) web.arena.Update() messages := readWebsocketMultiple(t, ws, 4) @@ -367,7 +369,7 @@ func TestMatchPlayWebsocketNotifications(t *testing.T) { messages = readWebsocketMultiple(t, ws, 2) statusReceived, matchTime := getStatusMatchTime(t, messages) assert.Equal(t, true, statusReceived) - assert.Equal(t, 3, matchTime.MatchState) + assert.Equal(t, field.AutoPeriod, matchTime.MatchState) assert.Equal(t, 3, matchTime.MatchTimeSec) web.arena.ScoringStatusNotifier.Notify() readWebsocketType(t, ws, "scoringStatus") @@ -377,7 +379,7 @@ func TestMatchPlayWebsocketNotifications(t *testing.T) { web.arena.Update() err = mapstructure.Decode(readWebsocketType(t, ws, "matchTime"), &matchTime) assert.Nil(t, err) - assert.Equal(t, 3, matchTime.MatchState) + assert.Equal(t, field.AutoPeriod, matchTime.MatchState) assert.Equal(t, 1, matchTime.MatchTimeSec) web.arena.MatchStartTime = time.Now().Add(-2*time.Second + 10*time.Millisecond) // Not crossed yet web.arena.Update() @@ -385,7 +387,7 @@ func TestMatchPlayWebsocketNotifications(t *testing.T) { web.arena.Update() err = mapstructure.Decode(readWebsocketType(t, ws, "matchTime"), &matchTime) assert.Nil(t, err) - assert.Equal(t, 3, matchTime.MatchState) + assert.Equal(t, field.AutoPeriod, matchTime.MatchState) assert.Equal(t, 2, matchTime.MatchTimeSec) // Check across a match state boundary. @@ -394,7 +396,7 @@ func TestMatchPlayWebsocketNotifications(t *testing.T) { web.arena.Update() statusReceived, matchTime = readWebsocketStatusMatchTime(t, ws) assert.Equal(t, true, statusReceived) - assert.Equal(t, 4, matchTime.MatchState) + assert.Equal(t, field.PausePeriod, matchTime.MatchState) assert.Equal(t, game.MatchTiming.WarmupDurationSec+game.MatchTiming.AutoDurationSec, matchTime.MatchTimeSec) } diff --git a/web/scoring_panel.go b/web/scoring_panel.go index 7d5b728..5512864 100644 --- a/web/scoring_panel.go +++ b/web/scoring_panel.go @@ -140,19 +140,19 @@ func (web *Web) scoringPanelWebsocketHandler(w http.ResponseWriter, r *http.Requ web.arena.ScoringStatusNotifier.Notify() } else if number, err := strconv.Atoi(command); err == nil && number >= 1 && number <= 9 { // Handle per-robot scoring fields. - if number <= 3 { + if number <= 3 && web.arena.MatchState == field.PreMatch { index := number - 1 score.RobotStartLevels[index]++ if score.RobotStartLevels[index] == 4 { score.RobotStartLevels[index] = 0 } scoreChanged = true - } else if number <= 6 && web.arena.MatchState != field.PreMatch { + } else if number > 3 && number <= 6 && web.arena.MatchState != field.PreMatch { index := number - 4 score.SandstormBonuses[index] = !score.SandstormBonuses[index] scoreChanged = true - } else if web.arena.MatchState != field.PreMatch { + } else if number > 6 && web.arena.MatchState != field.PreMatch { index := number - 7 score.RobotEndLevels[index]++ if score.RobotEndLevels[index] == 4 {