From 38580fae91399c9053a267f9bfa4e3c004cf1823 Mon Sep 17 00:00:00 2001 From: Patrick Fairbank Date: Sun, 12 May 2024 18:33:33 -0700 Subject: [PATCH] Add logic and tests for the 2024 Amp and Speaker elements. --- game/amp_speaker.go | 148 ++++++++++++++++++++++++ game/amp_speaker_test.go | 236 +++++++++++++++++++++++++++++++++++++++ game/match_timing.go | 7 +- 3 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 game/amp_speaker.go create mode 100644 game/amp_speaker_test.go diff --git a/game/amp_speaker.go b/game/amp_speaker.go new file mode 100644 index 00000000..ccd324ff --- /dev/null +++ b/game/amp_speaker.go @@ -0,0 +1,148 @@ +// Copyright 2024 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Scoring logic for the 2024 Amp and Speaker elements. + +package game + +import ( + "time" +) + +const bankedAmpNoteLimit = 2 + +type AmpSpeaker struct { + BankedAmpNotes int + CoopActivated bool + autoAmpNotes int + teleopAmpNotes int + autoSpeakerNotes int + teleopUnamplifiedSpeakerNotes int + teleopAmplifiedSpeakerNotes int + lastAmplifiedTime time.Time + lastAmplifiedSpeakerNotes int +} + +// Updates the internal state of the AmpSpeaker based on the PLC inputs. +func (ampSpeaker *AmpSpeaker) UpdateState( + ampNoteCount, speakerNoteCount int, amplifyButton, coopButton bool, matchStartTime, currentTime time.Time, +) { + newAmpNotes := ampNoteCount - ampSpeaker.ampNotesScored() + newSpeakerNotes := speakerNoteCount - ampSpeaker.speakerNotesScored() + + // Handle the autonomous period. + autoValidityCutoff := matchStartTime.Add(GetDurationToAutoEnd() + speakerAutoGracePeriodSec*time.Second) + if currentTime.Before(autoValidityCutoff) { + ampSpeaker.autoAmpNotes += newAmpNotes + ampSpeaker.BankedAmpNotes = min(ampSpeaker.BankedAmpNotes+newAmpNotes, bankedAmpNoteLimit) + ampSpeaker.autoSpeakerNotes += newSpeakerNotes + + // Bail out to avoid exercising the teleop logic. + return + } + + // Handle the Amp. + teleopAmpValidityCutoff := matchStartTime.Add(GetDurationToTeleopEnd()) + if currentTime.Before(teleopAmpValidityCutoff) { + // Handle incoming Amp notes. + ampSpeaker.teleopAmpNotes += newAmpNotes + if !ampSpeaker.isAmplified(currentTime, false) { + ampSpeaker.BankedAmpNotes = min(ampSpeaker.BankedAmpNotes+newAmpNotes, bankedAmpNoteLimit) + } + + // Handle the co-op button. + if coopButton && !ampSpeaker.CoopActivated && ampSpeaker.BankedAmpNotes >= 1 && + ampSpeaker.IsCoopWindowOpen(matchStartTime, currentTime) { + ampSpeaker.CoopActivated = true + ampSpeaker.BankedAmpNotes-- + } + + // Handle the amplify button. + if amplifyButton && !ampSpeaker.isAmplified(currentTime, false) && ampSpeaker.BankedAmpNotes >= 2 { + ampSpeaker.lastAmplifiedTime = currentTime + ampSpeaker.lastAmplifiedSpeakerNotes = 0 + ampSpeaker.BankedAmpNotes -= 2 + } + } + + // Handle the Speaker. + teleopSpeakerValidityCutoff := matchStartTime.Add( + GetDurationToTeleopEnd() + speakerTeleopGracePeriodSec*time.Second, + ) + if currentTime.Before(teleopSpeakerValidityCutoff) { + for newSpeakerNotes > 0 && ampSpeaker.isAmplified(currentTime, true) { + ampSpeaker.teleopAmplifiedSpeakerNotes++ + ampSpeaker.lastAmplifiedSpeakerNotes++ + newSpeakerNotes-- + } + ampSpeaker.teleopUnamplifiedSpeakerNotes += newSpeakerNotes + } +} + +// Returns the amount of time remaining in the current amplification period, or zero if not currently amplified. +func (ampSpeaker *AmpSpeaker) AmplifiedTimeRemaining(currentTime time.Time) float64 { + if !ampSpeaker.isAmplified(currentTime, false) { + return 0 + } + return float64(AmplificationDurationSec) - currentTime.Sub(ampSpeaker.lastAmplifiedTime).Seconds() +} + +// Returns true if the co-op window during the match is currently open. +func (ampSpeaker *AmpSpeaker) IsCoopWindowOpen(matchStartTime, currentTime time.Time) bool { + coopValidityCutoff := matchStartTime.Add(GetDurationToTeleopStart() + coopTeleopWindowSec*time.Second) + return MelodyBonusWithCoop > 0 && currentTime.Before(coopValidityCutoff) +} + +// Returns the total number of notes scored in the Amp and Speaker. +func (ampSpeaker *AmpSpeaker) TotalNotesScored() int { + return ampSpeaker.ampNotesScored() + ampSpeaker.speakerNotesScored() +} + +// Returns the total points scored in the Amp and Speaker during the autonomous period. +func (ampSpeaker *AmpSpeaker) AutoNotePoints() int { + return 2*ampSpeaker.autoAmpNotes + 5*ampSpeaker.autoSpeakerNotes +} + +// Returns the total points scored in the Amp and Speaker during the teleoperated period. +func (ampSpeaker *AmpSpeaker) TeleopNotePoints() int { + return ampSpeaker.teleopAmpNotes + + 2*ampSpeaker.teleopUnamplifiedSpeakerNotes + + 5*ampSpeaker.teleopAmplifiedSpeakerNotes +} + +// Returns the total points scored in the Amp. +func (ampSpeaker *AmpSpeaker) AmpPoints() int { + return 2*ampSpeaker.autoAmpNotes + ampSpeaker.teleopAmpNotes +} + +// Returns the total points scored in the Speaker. +func (ampSpeaker *AmpSpeaker) SpeakerPoints() int { + return 5*ampSpeaker.autoSpeakerNotes + + 2*ampSpeaker.teleopUnamplifiedSpeakerNotes + + 5*ampSpeaker.teleopAmplifiedSpeakerNotes +} + +// Returns the total number of notes scored in the Amp. +func (ampSpeaker *AmpSpeaker) ampNotesScored() int { + return ampSpeaker.autoAmpNotes + ampSpeaker.teleopAmpNotes +} + +// Returns the total number of notes scored in the Speaker. +func (ampSpeaker *AmpSpeaker) speakerNotesScored() int { + return ampSpeaker.autoSpeakerNotes + + ampSpeaker.teleopUnamplifiedSpeakerNotes + + ampSpeaker.teleopAmplifiedSpeakerNotes +} + +// Returns whether the Speaker should be counting new incoming notes as amplified. +func (ampSpeaker *AmpSpeaker) isAmplified(currentTime time.Time, includeGracePeriod bool) bool { + amplifiedValidityCutoff := ampSpeaker.lastAmplifiedTime.Add(time.Duration(AmplificationDurationSec) * time.Second) + if includeGracePeriod { + amplifiedValidityCutoff = amplifiedValidityCutoff.Add( + time.Duration(speakerAmplifiedGracePeriodSec) * time.Second, + ) + } + meetsTimeCriterion := currentTime.After(ampSpeaker.lastAmplifiedTime) && currentTime.Before(amplifiedValidityCutoff) + meetsNoteCriterion := AmplificationNoteLimit == 0 || ampSpeaker.lastAmplifiedSpeakerNotes < AmplificationNoteLimit + return meetsTimeCriterion && meetsNoteCriterion +} diff --git a/game/amp_speaker_test.go b/game/amp_speaker_test.go new file mode 100644 index 00000000..6485a9dc --- /dev/null +++ b/game/amp_speaker_test.go @@ -0,0 +1,236 @@ +// Copyright 2024 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) + +package game + +import ( + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +var matchStartTime = time.Unix(10, 0) + +func TestAmpSpeaker_CalculationMethods(t *testing.T) { + ampSpeaker := AmpSpeaker{ + autoAmpNotes: 1, + teleopAmpNotes: 2, + autoSpeakerNotes: 3, + teleopUnamplifiedSpeakerNotes: 5, + teleopAmplifiedSpeakerNotes: 8, + } + assert.Equal(t, 3, ampSpeaker.ampNotesScored()) + assert.Equal(t, 16, ampSpeaker.speakerNotesScored()) + assert.Equal(t, 19, ampSpeaker.TotalNotesScored()) + assert.Equal(t, 17, ampSpeaker.AutoNotePoints()) + assert.Equal(t, 52, ampSpeaker.TeleopNotePoints()) + assert.Equal(t, 4, ampSpeaker.AmpPoints()) + assert.Equal(t, 65, ampSpeaker.SpeakerPoints()) +} + +func TestAmpSpeaker_MatchSequence(t *testing.T) { + var ampSpeaker AmpSpeaker + assertAmpSpeaker := func( + autoAmpNotes, teleopAmpNotes, autoSpeakerNotes, teleopUnamplifiedSpeakerNotes, teleopAmplifiedSpeakerNotes int, + ) { + assert.Equal(t, autoAmpNotes, ampSpeaker.autoAmpNotes) + assert.Equal(t, teleopAmpNotes, ampSpeaker.teleopAmpNotes) + assert.Equal(t, autoSpeakerNotes, ampSpeaker.autoSpeakerNotes) + assert.Equal(t, teleopUnamplifiedSpeakerNotes, ampSpeaker.teleopUnamplifiedSpeakerNotes) + assert.Equal(t, teleopAmplifiedSpeakerNotes, ampSpeaker.teleopAmplifiedSpeakerNotes) + } + + ampSpeaker.UpdateState(0, 0, false, false, matchStartTime, timeAfterStart(0)) + assertAmpSpeaker(0, 0, 0, 0, 0) + + // Score in the Amp and Speaker during auto. + ampSpeaker.UpdateState(1, 0, false, false, matchStartTime, timeAfterStart(1)) + assertAmpSpeaker(1, 0, 0, 0, 0) + assert.Equal(t, 1, ampSpeaker.BankedAmpNotes) + assert.Equal(t, true, ampSpeaker.IsCoopWindowOpen(matchStartTime, timeAfterStart(1))) + ampSpeaker.UpdateState(2, 0, false, false, matchStartTime, timeAfterStart(2)) + assertAmpSpeaker(2, 0, 0, 0, 0) + assert.Equal(t, 2, ampSpeaker.BankedAmpNotes) + ampSpeaker.UpdateState(2, 3, false, false, matchStartTime, timeAfterStart(3)) + assertAmpSpeaker(2, 0, 3, 0, 0) + ampSpeaker.UpdateState(3, 4, false, false, matchStartTime, timeAfterStart(4)) + assertAmpSpeaker(3, 0, 4, 0, 0) + assert.Equal(t, 2, ampSpeaker.BankedAmpNotes) + + // Pressing the buttons during auto should not have any effect. + ampSpeaker.UpdateState(3, 4, true, true, matchStartTime, timeAfterStart(5)) + assert.Equal(t, 2, ampSpeaker.BankedAmpNotes) + assert.Equal(t, false, ampSpeaker.CoopActivated) + assert.Equal(t, 0.0, ampSpeaker.AmplifiedTimeRemaining(timeAfterStart(5))) + + // Score in the Amp and Speaker around the expiration of the grace period. + ampSpeaker.UpdateState(4, 6, false, false, matchStartTime, timeAfterStart(17.9)) + assertAmpSpeaker(4, 0, 6, 0, 0) + ampSpeaker.UpdateState(5, 8, false, false, matchStartTime, timeAfterStart(18.1)) + assertAmpSpeaker(4, 1, 6, 2, 0) + assert.Equal(t, 2, ampSpeaker.BankedAmpNotes) + + // Activate co-op. + ampSpeaker.UpdateState(5, 8, false, true, matchStartTime, timeAfterStart(20)) + assertAmpSpeaker(4, 1, 6, 2, 0) + assert.Equal(t, 1, ampSpeaker.BankedAmpNotes) + assert.Equal(t, true, ampSpeaker.CoopActivated) + + // Activate co-op a second time. + ampSpeaker.UpdateState(5, 8, false, false, matchStartTime, timeAfterStart(21)) + assertAmpSpeaker(4, 1, 6, 2, 0) + assert.Equal(t, 1, ampSpeaker.BankedAmpNotes) + assert.Equal(t, true, ampSpeaker.CoopActivated) + + // Try to activate amplify with insufficient notes banked. + ampSpeaker.UpdateState(5, 8, true, false, matchStartTime, timeAfterStart(22)) + assertAmpSpeaker(4, 1, 6, 2, 0) + assert.Equal(t, 1, ampSpeaker.BankedAmpNotes) + assert.Equal(t, false, ampSpeaker.isAmplified(timeAfterStart(22), false)) + + // Score more notes in the Amp and amplify. + ampSpeaker.UpdateState(7, 8, false, false, matchStartTime, timeAfterStart(23)) + assertAmpSpeaker(4, 3, 6, 2, 0) + assert.Equal(t, 2, ampSpeaker.BankedAmpNotes) + ampSpeaker.UpdateState(7, 8, true, false, matchStartTime, timeAfterStart(24)) + assertAmpSpeaker(4, 3, 6, 2, 0) + assert.Equal(t, 0, ampSpeaker.BankedAmpNotes) + assert.Equal(t, true, ampSpeaker.isAmplified(timeAfterStart(24.1), false)) + assert.Equal(t, 9.9, ampSpeaker.AmplifiedTimeRemaining(timeAfterStart(24.1))) + + // Score in the amplified Speaker and the Amp. + ampSpeaker.UpdateState(8, 11, false, false, matchStartTime, timeAfterStart(25)) + assertAmpSpeaker(4, 4, 6, 2, 3) + assert.Equal(t, 0, ampSpeaker.BankedAmpNotes) + assert.Equal(t, true, ampSpeaker.isAmplified(timeAfterStart(26), false)) + assert.Equal(t, 8.0, ampSpeaker.AmplifiedTimeRemaining(timeAfterStart(26))) + + // Exceed the note limit for the amplified Speaker. + ampSpeaker.UpdateState(8, 15, false, false, matchStartTime, timeAfterStart(27)) + assertAmpSpeaker(4, 4, 6, 5, 4) + assert.Equal(t, 0, ampSpeaker.BankedAmpNotes) + assert.Equal(t, false, ampSpeaker.isAmplified(timeAfterStart(27), false)) + assert.Equal(t, 0.0, ampSpeaker.AmplifiedTimeRemaining(timeAfterStart(27))) + + // Do another amplified cycle and test the grace period. + ampSpeaker.UpdateState(10, 15, true, false, matchStartTime, timeAfterStart(30)) + assertAmpSpeaker(4, 6, 6, 5, 4) + assert.Equal(t, 0, ampSpeaker.BankedAmpNotes) + assert.Equal(t, true, ampSpeaker.isAmplified(timeAfterStart(31), false)) + assert.Equal(t, 9.0, ampSpeaker.AmplifiedTimeRemaining(timeAfterStart(31))) + ampSpeaker.UpdateState(10, 16, true, false, matchStartTime, timeAfterStart(32)) + assertAmpSpeaker(4, 6, 6, 5, 5) + ampSpeaker.UpdateState(10, 17, true, false, matchStartTime, timeAfterStart(42.9)) + assertAmpSpeaker(4, 6, 6, 5, 6) + assert.Equal(t, true, ampSpeaker.isAmplified(timeAfterStart(42.9), true)) + assert.Equal(t, false, ampSpeaker.isAmplified(timeAfterStart(42.9), false)) + assert.Equal(t, 0.0, ampSpeaker.AmplifiedTimeRemaining(timeAfterStart(42.9))) + ampSpeaker.UpdateState(10, 18, true, false, matchStartTime, timeAfterStart(43.1)) + assertAmpSpeaker(4, 6, 6, 6, 6) + assert.Equal(t, false, ampSpeaker.isAmplified(timeAfterStart(43.1), true)) + + // Test around the end of the match and the grace period after. + ampSpeaker.UpdateState(11, 21, false, false, matchStartTime, timeAfterStart(152.9)) + assertAmpSpeaker(4, 7, 6, 9, 6) + assert.Equal(t, 1, ampSpeaker.BankedAmpNotes) + ampSpeaker.UpdateState(13, 23, true, false, matchStartTime, timeAfterStart(153.1)) + assertAmpSpeaker(4, 7, 6, 11, 6) + assert.Equal(t, 1, ampSpeaker.BankedAmpNotes) + ampSpeaker.UpdateState(13, 24, true, false, matchStartTime, timeAfterStart(157.9)) + assertAmpSpeaker(4, 7, 6, 12, 6) + ampSpeaker.UpdateState(13, 25, false, false, matchStartTime, timeAfterStart(158.1)) + assertAmpSpeaker(4, 7, 6, 12, 6) + + // Reset the AmpSpeaker to test different conditions and settings. + ampSpeaker = AmpSpeaker{} + assertAmpSpeaker(0, 0, 0, 0, 0) + assert.Equal(t, 0, ampSpeaker.BankedAmpNotes) + + // Attempt to co-op with insufficient notes banked. + ampSpeaker.UpdateState(0, 0, false, true, matchStartTime, timeAfterStart(20)) + assert.Equal(t, false, ampSpeaker.CoopActivated) + + // Attempt to co-op just before the window has closed. + assert.Equal(t, true, ampSpeaker.IsCoopWindowOpen(matchStartTime, timeAfterStart(62.9))) + ampSpeaker.UpdateState(1, 0, false, true, matchStartTime, timeAfterStart(62.9)) + assertAmpSpeaker(0, 1, 0, 0, 0) + assert.Equal(t, 0, ampSpeaker.BankedAmpNotes) + assert.Equal(t, true, ampSpeaker.CoopActivated) + assert.Equal(t, true, ampSpeaker.IsCoopWindowOpen(matchStartTime, timeAfterStart(62.9))) + + // Undo the co-op and try again after the window has closed. + ampSpeaker = AmpSpeaker{} + assertAmpSpeaker(0, 0, 0, 0, 0) + assert.Equal(t, false, ampSpeaker.IsCoopWindowOpen(matchStartTime, timeAfterStart(63.1))) + ampSpeaker.UpdateState(1, 0, false, true, matchStartTime, timeAfterStart(63.1)) + assertAmpSpeaker(0, 1, 0, 0, 0) + assert.Equal(t, 1, ampSpeaker.BankedAmpNotes) + assert.Equal(t, false, ampSpeaker.CoopActivated) + + // Backtrack and disable co-op. + assertAmpSpeaker(0, 1, 0, 0, 0) + assert.Equal(t, 1, ampSpeaker.BankedAmpNotes) + assert.Equal(t, true, ampSpeaker.IsCoopWindowOpen(matchStartTime, timeAfterStart(60))) + MelodyBonusWithCoop = 0 + assert.Equal(t, false, ampSpeaker.IsCoopWindowOpen(matchStartTime, timeAfterStart(60))) + ampSpeaker.UpdateState(2, 0, false, true, matchStartTime, timeAfterStart(60)) + assertAmpSpeaker(0, 2, 0, 0, 0) + assert.Equal(t, 2, ampSpeaker.BankedAmpNotes) + assert.Equal(t, false, ampSpeaker.CoopActivated) + + // Test with different amplification note limit and duration. + AmplificationNoteLimit = 3 + AmplificationDurationSec = 6 + ampSpeaker.UpdateState(2, 0, true, false, matchStartTime, timeAfterStart(70)) + assertAmpSpeaker(0, 2, 0, 0, 0) + ampSpeaker.UpdateState(2, 1, true, false, matchStartTime, timeAfterStart(71)) + assertAmpSpeaker(0, 2, 0, 0, 1) + assert.Equal(t, true, ampSpeaker.isAmplified(timeAfterStart(71), false)) + assert.Equal(t, 5.0, ampSpeaker.AmplifiedTimeRemaining(timeAfterStart(71))) + ampSpeaker.UpdateState(2, 4, false, false, matchStartTime, timeAfterStart(72)) + assertAmpSpeaker(0, 2, 0, 1, 3) + assert.Equal(t, false, ampSpeaker.isAmplified(timeAfterStart(72), true)) + assert.Equal(t, 0.0, ampSpeaker.AmplifiedTimeRemaining(timeAfterStart(72))) + + // Test with no amplification note limit and long duration. + AmplificationNoteLimit = 0 + AmplificationDurationSec = 23 + ampSpeaker.lastAmplifiedTime = time.Time{} + ampSpeaker.UpdateState(4, 4, true, false, matchStartTime, timeAfterStart(73)) + assertAmpSpeaker(0, 4, 0, 1, 3) + assert.Equal(t, 0, ampSpeaker.BankedAmpNotes) + assert.Equal(t, true, ampSpeaker.isAmplified(timeAfterStart(74), true)) + assert.Equal(t, 22.0, ampSpeaker.AmplifiedTimeRemaining(timeAfterStart(74))) + ampSpeaker.UpdateState(100, 44, false, false, matchStartTime, timeAfterStart(94)) + assertAmpSpeaker(0, 100, 0, 1, 43) + assert.Equal(t, 0, ampSpeaker.BankedAmpNotes) + assert.Equal(t, true, ampSpeaker.isAmplified(timeAfterStart(94), true)) + assert.Equal(t, 2.0, ampSpeaker.AmplifiedTimeRemaining(timeAfterStart(94))) + ampSpeaker.UpdateState(101, 57, false, false, matchStartTime, timeAfterStart(98.9)) + assertAmpSpeaker(0, 101, 0, 1, 56) + assert.Equal(t, 1, ampSpeaker.BankedAmpNotes) + assert.Equal(t, true, ampSpeaker.isAmplified(timeAfterStart(98.9), true)) + assert.Less(t, ampSpeaker.AmplifiedTimeRemaining(timeAfterStart(98.9)), 0.2) + ampSpeaker.UpdateState(102, 60, false, false, matchStartTime, timeAfterStart(99.1)) + assertAmpSpeaker(0, 102, 0, 4, 56) + assert.Equal(t, 2, ampSpeaker.BankedAmpNotes) + assert.Equal(t, false, ampSpeaker.isAmplified(timeAfterStart(99.1), true)) + assert.Equal(t, 0.0, ampSpeaker.AmplifiedTimeRemaining(timeAfterStart(99.1))) + + // Restore global constants. + AmplificationNoteLimit = 4 + AmplificationDurationSec = 10 + + // Test hitting the amplification button just before the end of the match. + ampSpeaker.UpdateState(102, 60, true, false, matchStartTime, timeAfterStart(152)) + ampSpeaker.UpdateState(102, 63, true, false, matchStartTime, timeAfterStart(157)) + assertAmpSpeaker(0, 102, 0, 4, 59) + assert.Equal(t, true, ampSpeaker.isAmplified(timeAfterStart(157), true)) + assert.Equal(t, 5.0, ampSpeaker.AmplifiedTimeRemaining(timeAfterStart(157))) + ampSpeaker.UpdateState(102, 66, true, false, matchStartTime, timeAfterStart(157.9)) + assertAmpSpeaker(0, 102, 0, 6, 60) +} + +func timeAfterStart(sec float32) time.Time { + return matchStartTime.Add(time.Duration(1000*sec) * time.Millisecond) +} diff --git a/game/match_timing.go b/game/match_timing.go index bad0f9fe..982df57f 100644 --- a/game/match_timing.go +++ b/game/match_timing.go @@ -7,7 +7,12 @@ package game import "time" -var ChargeStationTeleopGracePeriod = 3 * time.Second +const ( + speakerAutoGracePeriodSec = 3 + speakerTeleopGracePeriodSec = 5 + speakerAmplifiedGracePeriodSec = 3 + coopTeleopWindowSec = 45 +) var MatchTiming = struct { WarmupDurationSec int