diff --git a/cmd/skyeye/main.go b/cmd/skyeye/main.go index 4c3bc989..2ac8e2d0 100644 --- a/cmd/skyeye/main.go +++ b/cmd/skyeye/main.go @@ -74,6 +74,7 @@ var ( discordWebhookID string discordWebhookToken string exitAfter time.Duration + locationsFile string ) func init() { @@ -142,6 +143,7 @@ func init() { skyeye.Flags().DurationVar(&threatMonitoringInterval, "threat-monitoring-interval", 3*time.Minute, "How often to broadcast THREAT") skyeye.Flags().Float64Var(&mandatoryThreatRadiusNM, "mandatory-threat-radius", 25, "Briefed radius for mandatory THREAT calls, in nautical miles") skyeye.Flags().BoolVar(&threatMonitoringRequiresSRS, "threat-monitoring-requires-srs", true, "Require aircraft to be on SRS to receive THREAT calls. Only useful to disable when debugging") + skyeye.Flags().StringVar(&locationsFile, "locations-file", "", "Path to file containing additional locations that may be referenced in ALPHA CHECK and VECTOR calls.") // Tracing skyeye.Flags().BoolVar(&enableTracing, "enable-tracing", false, "Enable tracing") diff --git a/docs/LOCATIONS.md b/docs/LOCATIONS.md new file mode 100644 index 00000000..41cc6d92 --- /dev/null +++ b/docs/LOCATIONS.md @@ -0,0 +1,28 @@ +# Custom Locations + +Server administrators can define custom locations which players can reference in ALPHA CHECK and VECTOR requests. You can define useful locations like friendly bases or fix points. + +To use this feature, create a `locations.json` file. The content of the file should be a JSON array of objects. Each object should have the following properties: + +- `names`: An array of strings, which are the names of the location. These names are used in the ALPHA CHECK and VECTOR requests. You can define multiple names for the same location - for example, you might have a location with both the names "Incirlik" and "Home plate". Note that the names "Bullseye" and "Tanker" are reserved and cannot be used as custom location names. +- `latitude`: A floating point number, which is the latitude of the location in decimal degrees. This should be a number between -90 and 90. +- `longitude`: A floating point number, which is the longitude of the location in decimal degrees. This should be a number between -180 and 180. + +Example: + +```json +[ + { + "names": ["Incirlik", "Home plate"], + "latitude": 37.001166662, + "longitude": 35.422164978 + }, + { + "names": ["Hatay", "Divert option"], + "latitude": 36.362778, + "longitude": 36.282222 + } +] +``` + +Set the path to the `locations.json` file in the `locations-file` setting in SkyEye's configuration. diff --git a/docs/PLAYER.md b/docs/PLAYER.md index 8a717be5..e8a8bb7b 100644 --- a/docs/PLAYER.md +++ b/docs/PLAYER.md @@ -168,7 +168,7 @@ Tips: Keyword: `ALPHA` -Function: The GCI will check if they see you on scope and tell you your approximate current location in bullseye format. +Function: The GCI will check if they see you on scope and tell you your approximate current location as a bearing and range from a given point. You can ask for an Alpha Check from bullseye, or from a [named location](LOCATIONS.md). The bullseye is used if no point is specified. Use: You can use this to coarsely check your INS navigation system in an aircraft without GPS. It is accurate to within several miles (accounting for potential lag time between when the bot checks the scope and when the response is sent on the radio). @@ -189,6 +189,29 @@ YELLOW 13: "Goliath Yellow One Three alpha" GOLIATH: "Yellow One Three, Goliath, contact, alpha check bullseye 088/5" ``` +### VECTOR + +Keyword: `VECTOR` + +Function: The GCI will check if you are on scope and tell you the approximate bearing and range from you to a given point. You can ask for a vector to bullseye, "tanker", or to a [named location](LOCATIONS.md). The bullseye is used if no point is specified. + +Asking for a vector to "tanker" requests a vector to the nearest tanker aircraft which is compatible with your aircraft. + +Use: This is the reciprocal of an ALPHA CHECK, and is useful for navigation assistance. + +Examples: + +``` +MOBIUS 1: "Thunderhead Mobius One vector to home plate." +THUNDERHEAD: "Mobius One, Thunderhead, vector to HOME PLATE 010/122" +``` + +``` +WARDOG 14: "Magic, Wardog One Four, vector to tanker" +MAGIC: "Wardog One Four, vector to Shell One One 099/75" +``` + + ### BOGEY DOPE Keyword: `BOGEY` diff --git a/internal/application/app.go b/internal/application/app.go index 6c0c8a26..712cf0c6 100644 --- a/internal/application/app.go +++ b/internal/application/app.go @@ -17,6 +17,7 @@ import ( "github.com/dharmab/skyeye/pkg/commands" "github.com/dharmab/skyeye/pkg/composer" "github.com/dharmab/skyeye/pkg/controller" + loc "github.com/dharmab/skyeye/pkg/locations" "github.com/dharmab/skyeye/pkg/parser" "github.com/dharmab/skyeye/pkg/radar" "github.com/dharmab/skyeye/pkg/recognizer" @@ -167,7 +168,7 @@ func NewApplication(config conf.Configuration) (*Application, error) { } log.Info().Msg("constructing request parser") - requestParser := parser.New(config.Callsign, config.EnableTranscriptionLogging) + requestParser := parser.New(config.Callsign, []string{}, config.EnableTranscriptionLogging) log.Info().Msg("constructing radar scope") @@ -182,6 +183,7 @@ func NewApplication(config conf.Configuration) (*Application, error) { config.EnableThreatMonitoring, config.ThreatMonitoringInterval, config.ThreatMonitoringRequiresSRS, + []loc.Location{}, ) log.Info().Msg("constructing response composer") diff --git a/internal/application/compose.go b/internal/application/compose.go index cce2c437..e5d6ec52 100644 --- a/internal/application/compose.go +++ b/internal/application/compose.go @@ -56,6 +56,8 @@ func (a *Application) composeCall(ctx context.Context, call any, out chan<- Mess response = a.composer.ComposeSpikedResponse(c) case brevity.TripwireResponse: response = a.composer.ComposeTripwireResponse(c) + case brevity.VectorResponse: + response = a.composer.ComposeVectorResponse(c) case brevity.SunriseCall: response = a.composer.ComposeSunriseCall(c) case brevity.ThreatCall: diff --git a/internal/application/control.go b/internal/application/control.go index 10515500..0bb9ca80 100644 --- a/internal/application/control.go +++ b/internal/application/control.go @@ -55,6 +55,8 @@ func (a *Application) handleRequest(ctx context.Context, r any) { a.controller.HandleSpiked(ctx, request) case *brevity.TripwireRequest: a.controller.HandleTripwire(ctx, request) + case *brevity.VectorRequest: + a.controller.HandleVector(ctx, request) case *brevity.UnableToUnderstandRequest: a.controller.HandleUnableToUnderstand(ctx, request) default: diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 7b0fa9a2..0985540c 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -91,6 +91,8 @@ type Configuration struct { // ThreatMonitoringRequiresSRS controls whether threat calls are issued to aircraft that are not on an SRS frequency. This is mostly // for debugging. ThreatMonitoringRequiresSRS bool + // Locations is a slice of named locations that can be referenced in ALPHA CHECK and VECTOR calls. + Locations []*Location // EnableTracing controls whether to publish traces EnableTracing bool // DiscordWebhookID is the ID of the Discord webhook @@ -101,6 +103,13 @@ type Configuration struct { ExitAfter time.Duration } +type Location struct { + // Names of the location + Names []string `json:"names"` + // Coordinates of the location as a GeoJSON coordinates array with a single member + Coordinates [][]float64 `json:"coordinates"` +} + var DefaultCallsigns = []string{"Sky Eye", "Thunderhead", "Eagle Eye", "Ghost Eye", "Sky Keeper", "Bandog", "Long Caster", "Galaxy"} var DefaultPictureRadius = 300 * unit.NauticalMile diff --git a/pkg/brevity/braa.go b/pkg/brevity/braa.go index 9ad00699..6c17d939 100644 --- a/pkg/brevity/braa.go +++ b/pkg/brevity/braa.go @@ -2,7 +2,6 @@ package brevity import ( "fmt" - "math" "github.com/dharmab/skyeye/pkg/bearings" "github.com/dharmab/skyeye/pkg/spatial" @@ -20,10 +19,7 @@ type BRAA interface { // BRA is an abbreviated form of BRAA without aspect. type BRA interface { - // Bearing is the heading from the fighter to the contact, rounded to the nearest degree. - Bearing() bearings.Bearing - // Range is the distance from the fighter to the contact, rounded to the nearest nautical mile. - Range() unit.Length + Vector // Altitude of the contact above sea level, rounded to the nearest thousands of feet. Altitude() unit.Length // Altitude STACKS of the contact above sea level, rounded to the nearest thousands of feet. @@ -31,9 +27,8 @@ type BRA interface { } type bra struct { - bearing bearings.Bearing - _range unit.Length - stacks []Stack + vector + stacks []Stack } // NewBRA creates a new [BRA]. @@ -42,22 +37,14 @@ func NewBRA(b bearings.Bearing, r unit.Length, a ...unit.Length) BRA { log.Warn().Stringer("bearing", b).Msg("bearing provided to NewBRA should be magnetic") } return &bra{ - bearing: b, - _range: r, - stacks: Stacks(a...), + vector: vector{ + bearing: b, + distance: r, + }, + stacks: Stacks(a...), } } -// Bearing implements [BRA.Bearing]. -func (b *bra) Bearing() bearings.Bearing { - return b.bearing -} - -// Range implements [BRA.Range]. -func (b *bra) Range() unit.Length { - return unit.Length(math.Round(b._range.NauticalMiles())) * unit.NauticalMile -} - // Altitude implements [BRA.Altitude]. func (b *bra) Altitude() unit.Length { if len(b.stacks) == 0 { diff --git a/pkg/brevity/bullseye.go b/pkg/brevity/bullseye.go index efff32bb..45452a3a 100644 --- a/pkg/brevity/bullseye.go +++ b/pkg/brevity/bullseye.go @@ -2,7 +2,6 @@ package brevity import ( "fmt" - "math" "github.com/dharmab/skyeye/pkg/bearings" "github.com/martinlindhe/unit" @@ -11,32 +10,38 @@ import ( // Bullseye is a magnetic bearing and distance from a reference point called the BULLSEYE. // Reference: ATP 3-52.4 Chapter IV section 4 subsection a. -type Bullseye struct { - bearing bearings.Bearing - distance unit.Length +type Bullseye interface { + Bearing() bearings.Bearing + Distance() unit.Length +} + +type bullseye struct { + vector } // NewBullseye creates a new [Bullseye]. -func NewBullseye(bearing bearings.Bearing, distance unit.Length) *Bullseye { +func NewBullseye(bearing bearings.Bearing, distance unit.Length) Bullseye { if !bearing.IsMagnetic() { log.Warn().Stringer("bearing", bearing).Msg("bearing provided to NewBullseye should be magnetic") } - return &Bullseye{ - bearing: bearing, - distance: distance, + return &bullseye{ + vector: vector{ + bearing: bearing, + distance: distance, + }, } } // Bearing from the BULLSEYE to the contact, rounded to the nearest degree. -func (b *Bullseye) Bearing() bearings.Bearing { - return b.bearing +func (b *bullseye) Bearing() bearings.Bearing { + return b.vector.Bearing() } // Distance from the BULLSEYE to the contact, rounded to the nearest nautical mile. -func (b *Bullseye) Distance() unit.Length { - return unit.Length(math.Round(b.distance.NauticalMiles())) * unit.NauticalMile +func (b *bullseye) Distance() unit.Length { + return b.Range() } -func (b *Bullseye) String() string { - return fmt.Sprintf("%s/%.0f", b.bearing, b.distance.NauticalMiles()) +func (b *bullseye) String() string { + return fmt.Sprintf("%s/%.0f", b.Bearing(), b.Distance().NauticalMiles()) } diff --git a/pkg/brevity/declare.go b/pkg/brevity/declare.go index 2f791695..cccfa11c 100644 --- a/pkg/brevity/declare.go +++ b/pkg/brevity/declare.go @@ -75,7 +75,7 @@ func (r DeclareRequest) String() string { s += fmt.Sprintf(", altitude %.0f", r.Altitude.Feet()) } } else { - s += fmt.Sprintf("bullseye %s", &r.Bullseye) + s += fmt.Sprintf("bullseye %s", r.Bullseye) } if r.Track != UnknownDirection { s += fmt.Sprintf(", track %s", r.Track) diff --git a/pkg/brevity/group.go b/pkg/brevity/group.go index 6370f236..a07f6d10 100644 --- a/pkg/brevity/group.go +++ b/pkg/brevity/group.go @@ -19,7 +19,7 @@ type Group interface { // Contacts is the number of contacts in the group. Contacts() int // Bullseye is the location of the group. This may be nil for BOGEY DOPE, SNAPLOCK, and THREAT calls. - Bullseye() *Bullseye + Bullseye() Bullseye // Altitude is the group's highest altitude. This may be zero for BOGEY DOPE, SNAPLOCK, and THREAT calls. Altitude() unit.Length // Stacks are the group's altitude STACKS, ordered from highest to lowest in intervals of at least 10,000 feet. diff --git a/pkg/brevity/vector.go b/pkg/brevity/vector.go new file mode 100644 index 00000000..02bd616d --- /dev/null +++ b/pkg/brevity/vector.go @@ -0,0 +1,62 @@ +package brevity + +import ( + "math" + + "github.com/dharmab/skyeye/pkg/bearings" + "github.com/martinlindhe/unit" +) + +type Vector interface { + // Bearing is the heading from the fighter to the target location, rounded to the nearest degree. + Bearing() bearings.Bearing + // Range is the distance from the fighter to the target location, rounded to the nearest nautical mile. + Range() unit.Length +} + +type vector struct { + bearing bearings.Bearing + distance unit.Length +} + +func NewVector(bearing bearings.Bearing, distance unit.Length) Vector { + return &vector{ + bearing: bearing, + distance: distance, + } +} + +// Bearing implements [Vector.Bearing]. +func (v *vector) Bearing() bearings.Bearing { + return v.bearing +} + +// Range implements [Vector.Range]. +func (v *vector) Range() unit.Length { + return unit.Length(math.Round(v.distance.NauticalMiles())) * unit.NauticalMile +} + +type VectorRequest struct { + // Callsign of the friendly aircraft requesting the vector. + Callsign string + // Location to which the friendly aircraft is requesting a vector. + Location string +} + +func (r VectorRequest) String() string { + return "VECTOR to " + r.Location + " for " + r.Callsign +} + +type VectorResponse struct { + // Callsign of the friendly aircraft requesting the vector. + Callsign string + // Location which the friendly aircraft is requesting a vector. + Location string + // Contact is true if the callsign was correlated to an aircraft on frequency, otherwise false. + Contact bool + // Status is true if the vector was successfully computed, otherwise false. + Status bool + // Vector is the computed vector to the target location, if available. + // // If Status is false, this may be nil. + Vector Vector +} diff --git a/pkg/composer/faded.go b/pkg/composer/faded.go index eafd4369..5ec17edf 100644 --- a/pkg/composer/faded.go +++ b/pkg/composer/faded.go @@ -14,7 +14,7 @@ func (c *Composer) ComposeFadedCall(call brevity.FadedCall) (response NaturalLan } if bullseye := call.Group.Bullseye(); bullseye != nil { - bullseye := c.composeBullseye(*bullseye) + bullseye := c.composeBullseye(bullseye) response.WriteResponse(bullseye) } diff --git a/pkg/composer/group.go b/pkg/composer/group.go index 645a328b..93b69a0b 100644 --- a/pkg/composer/group.go +++ b/pkg/composer/group.go @@ -51,7 +51,7 @@ func (c *Composer) composeGroup(group brevity.Group) (response NaturalLanguageRe stacks := group.Stacks() isTrackKnown := group.Track() != brevity.UnknownDirection if group.Bullseye() != nil { - bullseye := c.composeBullseye(*group.Bullseye()) + bullseye := c.composeBullseye(group.Bullseye()) altitude := c.composeAltitudeStacks(stacks, group.Declaration()) response.Write( fmt.Sprintf("%s %s, %s", label, bullseye.Speech, altitude), diff --git a/pkg/composer/vector.go b/pkg/composer/vector.go new file mode 100644 index 00000000..030ac271 --- /dev/null +++ b/pkg/composer/vector.go @@ -0,0 +1,48 @@ +package composer + +import ( + "fmt" + + "github.com/dharmab/skyeye/pkg/brevity" + "github.com/rs/zerolog/log" +) + +func (_ *Composer) ComposeVectorResponse(response brevity.VectorResponse) NaturalLanguageResponse { + if !response.Contact { + reply := response.Callsign + ", negative contact" + return NaturalLanguageResponse{ + Subtitle: reply, + Speech: reply, + } + } + + if !response.Status { + reply := response.Callsign + ", unable to provide vector to " + response.Location + return NaturalLanguageResponse{ + Subtitle: reply, + Speech: reply, + } + } + + if !response.Vector.Bearing().IsMagnetic() { + log.Error().Stringer("bearing", response.Vector.Bearing()).Msg("bearing provided to ComposeVectorResponse should be magnetic") + } + + distance := int(response.Vector.Range().NauticalMiles()) + return NaturalLanguageResponse{ + Subtitle: fmt.Sprintf( + "%s, vector to %s, %s/%d", + response.Callsign, + response.Location, + response.Vector.Bearing().String(), + distance, + ), + Speech: fmt.Sprintf( + "%s, vector to %s, %s %d", + response.Callsign, + response.Location, + pronounceBearing(response.Vector.Bearing()), + distance, + ), + } +} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 2c80da22..08db99a9 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -13,6 +13,8 @@ import ( "github.com/lithammer/shortuuid/v3" "github.com/martinlindhe/unit" "github.com/rs/zerolog/log" + + loc "github.com/dharmab/skyeye/pkg/locations" ) var ( @@ -42,6 +44,8 @@ type Controller struct { // scope provides information about the airspace. scope *radar.Radar + locations []loc.Location + // srsClient is used to check if relevant friendly aircraft are on frequency before broadcasting calls. srsClient *simpleradio.Client @@ -85,10 +89,12 @@ func New( enableThreatMonitoring bool, threatMonitoringCooldown time.Duration, threatMonitoringRequiresSRS bool, + locations []loc.Location, ) *Controller { return &Controller{ coalition: coalition, scope: rdr, + locations: locations, srsClient: srsClient, enableAutomaticPicture: enableAutomaticPicture, pictureBroadcastInterval: pictureBroadcastInterval, diff --git a/pkg/controller/vector.go b/pkg/controller/vector.go new file mode 100644 index 00000000..8830e5dc --- /dev/null +++ b/pkg/controller/vector.go @@ -0,0 +1,48 @@ +package controller + +import ( + "context" + "slices" + + "github.com/dharmab/skyeye/pkg/brevity" + loc "github.com/dharmab/skyeye/pkg/locations" + "github.com/dharmab/skyeye/pkg/spatial" + "github.com/dharmab/skyeye/pkg/trackfiles" + "github.com/rs/zerolog/log" +) + +func (c *Controller) HandleVector(ctx context.Context, request *brevity.VectorRequest) { + logger := log.With().Str("callsign", request.Callsign).Type("type", request).Logger() + logger.Debug().Msg("handling request") + + response := brevity.VectorResponse{ + Callsign: request.Callsign, + Location: request.Location, + } + + var trackfile *trackfiles.Trackfile + response.Callsign, trackfile, response.Contact = c.findCallsign(request.Callsign) + + var targetLocation *loc.Location + for _, location := range c.locations { + if location.Names == nil { + continue + } + if slices.Contains(location.Names, request.Location) { + targetLocation = &location + break + } + } + response.Status = targetLocation != nil + + if response.Contact && response.Status { + origin := trackfile.LastKnown().Point + target := targetLocation.Point() + declination := c.scope.Declination(origin) + bearing := spatial.TrueBearing(origin, target).Magnetic(declination) + distance := spatial.Distance(origin, target) + response.Vector = brevity.NewVector(bearing, distance) + } + + c.calls <- NewCall(ctx, response) +} diff --git a/pkg/locations/location.go b/pkg/locations/location.go new file mode 100644 index 00000000..fea60b86 --- /dev/null +++ b/pkg/locations/location.go @@ -0,0 +1,13 @@ +package locations + +import "github.com/paulmach/orb" + +type Location struct { + Names []string `json:"names"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` +} + +func (l Location) Point() orb.Point { + return orb.Point{l.Longitude, l.Latitude} +} diff --git a/pkg/parser/alphacheck_test.go b/pkg/parser/alphacheck_test.go index db4090ee..e32c94c9 100644 --- a/pkg/parser/alphacheck_test.go +++ b/pkg/parser/alphacheck_test.go @@ -65,7 +65,7 @@ func TestParserAlphaCheck(t *testing.T) { }, }, } - runParserTestCases(t, New(TestCallsign, true), testCases, func(t *testing.T, test parserTestCase, request any) { + runParserTestCases(t, New(TestCallsign, []string{}, true), testCases, func(t *testing.T, test parserTestCase, request any) { t.Helper() expected := test.expected.(*brevity.AlphaCheckRequest) actual := request.(*brevity.AlphaCheckRequest) diff --git a/pkg/parser/bogeydope_test.go b/pkg/parser/bogeydope_test.go index 4a077b16..eb477579 100644 --- a/pkg/parser/bogeydope_test.go +++ b/pkg/parser/bogeydope_test.go @@ -304,7 +304,7 @@ func TestParserBogeyDope(t *testing.T) { } testCases = append(testCases, tc) } - runParserTestCases(t, New(TestCallsign, true), testCases, func(t *testing.T, test parserTestCase, request any) { + runParserTestCases(t, New(TestCallsign, []string{}, true), testCases, func(t *testing.T, test parserTestCase, request any) { t.Helper() expected := test.expected.(*brevity.BogeyDopeRequest) actual := request.(*brevity.BogeyDopeRequest) diff --git a/pkg/parser/checkin_test.go b/pkg/parser/checkin_test.go index 045c9fea..9e1c56ef 100644 --- a/pkg/parser/checkin_test.go +++ b/pkg/parser/checkin_test.go @@ -30,7 +30,7 @@ func TestParserCheckIn(t *testing.T) { }, } - runParserTestCases(t, New(TestCallsign, true), testCases, func(t *testing.T, test parserTestCase, request any) { + runParserTestCases(t, New(TestCallsign, []string{}, true), testCases, func(t *testing.T, test parserTestCase, request any) { t.Helper() expected := test.expected.(*brevity.CheckInRequest) actual := request.(*brevity.CheckInRequest) diff --git a/pkg/parser/declare.go b/pkg/parser/declare.go index 43882753..9b2fc1e1 100644 --- a/pkg/parser/declare.go +++ b/pkg/parser/declare.go @@ -11,7 +11,7 @@ import ( ) func parseDeclare(callsign string, scanner *bufio.Scanner) (*brevity.DeclareRequest, bool) { - var bullseye *brevity.Bullseye + var bullseye brevity.Bullseye var bearing bearings.Bearing var _range unit.Length var isBRAA bool @@ -115,7 +115,7 @@ func parseDeclare(callsign string, scanner *bufio.Scanner) (*brevity.DeclareRequ } return &brevity.DeclareRequest{ Callsign: callsign, - Bullseye: *bullseye, + Bullseye: bullseye, Altitude: altitude, Track: track, IsAmbiguous: isAmbiguous, diff --git a/pkg/parser/declare_test.go b/pkg/parser/declare_test.go index a3ae0673..006ff681 100644 --- a/pkg/parser/declare_test.go +++ b/pkg/parser/declare_test.go @@ -17,7 +17,7 @@ func TestParserDeclare(t *testing.T) { text: "anyface, chevy one one, declare, 075 26 2000", expected: &brevity.DeclareRequest{ Callsign: "chevy 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(75*unit.Degree), 26*unit.NauticalMile, ), @@ -30,7 +30,7 @@ func TestParserDeclare(t *testing.T) { text: "anyface, chevy one one, declare, 075 26", expected: &brevity.DeclareRequest{ Callsign: "chevy 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(75*unit.Degree), 26*unit.NauticalMile, ), @@ -43,7 +43,7 @@ func TestParserDeclare(t *testing.T) { text: "anyface, chevy one one, declare, 075 26 at 2000", expected: &brevity.DeclareRequest{ Callsign: "chevy 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(75*unit.Degree), 26*unit.NauticalMile, ), @@ -56,7 +56,7 @@ func TestParserDeclare(t *testing.T) { text: "anyface, chevy one one, declare bullseye 075 for 26 at 2000", expected: &brevity.DeclareRequest{ Callsign: "chevy 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(75*unit.Degree), 26*unit.NauticalMile, ), @@ -68,7 +68,7 @@ func TestParserDeclare(t *testing.T) { text: "anyface, chevy one one, declare, 075 26 altitude 2000", expected: &brevity.DeclareRequest{ Callsign: "chevy 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(75*unit.Degree), 26*unit.NauticalMile, ), @@ -81,7 +81,7 @@ func TestParserDeclare(t *testing.T) { text: "anyface, tater 1-1, declare bullseye 0-5-4, 123, 3000", expected: &brevity.DeclareRequest{ Callsign: "tater 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(54*unit.Degree), 123*unit.NauticalMile, ), @@ -93,7 +93,7 @@ func TestParserDeclare(t *testing.T) { text: "anyface Fox 1 2 declare bullseye 043 102 12,000", expected: &brevity.DeclareRequest{ Callsign: "fox 1 2", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(43*unit.Degree), 102*unit.NauticalMile, ), @@ -105,7 +105,7 @@ func TestParserDeclare(t *testing.T) { text: "anyface, Chaos 11, declare bullseye 076 44 3000.", expected: &brevity.DeclareRequest{ Callsign: "chaos 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(76*unit.Degree), 44*unit.NauticalMile, ), @@ -117,7 +117,7 @@ func TestParserDeclare(t *testing.T) { text: "anyface, dog one one, declare, bullseye 075-26-2000", expected: &brevity.DeclareRequest{ Callsign: "dog 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(75*unit.Degree), 26*unit.NauticalMile, ), @@ -129,7 +129,7 @@ func TestParserDeclare(t *testing.T) { text: "Anyface Goblin11, declare 052-77-2000", expected: &brevity.DeclareRequest{ Callsign: "goblin 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(52*unit.Degree), 77*unit.NauticalMile, ), @@ -204,7 +204,7 @@ func TestParserDeclare(t *testing.T) { text: "anyface mobius1, declare 177.29", expected: &brevity.DeclareRequest{ Callsign: "mobius 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(177*unit.Degree), 29*unit.NauticalMile, ), @@ -216,7 +216,7 @@ func TestParserDeclare(t *testing.T) { text: "ANYFACE, DAGGER11, DECLARE, BULLSEYE 01464.", expected: &brevity.DeclareRequest{ Callsign: "dagger 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(14*unit.Degree), 64*unit.NauticalMile, ), @@ -227,7 +227,7 @@ func TestParserDeclare(t *testing.T) { text: "ANYFACE, DAGGER 1-1, DECLARE, BULZYE 01162", expected: &brevity.DeclareRequest{ Callsign: "dagger 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(11*unit.Degree), 62*unit.NauticalMile, ), @@ -238,7 +238,7 @@ func TestParserDeclare(t *testing.T) { text: "ANYFACE, DAGGER 1-1, DECLARE, BULZYE 011 62, INJELS 18.", expected: &brevity.DeclareRequest{ Callsign: "dagger 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(11*unit.Degree), 62*unit.NauticalMile, ), @@ -250,7 +250,7 @@ func TestParserDeclare(t *testing.T) { text: "anyface, 140, declare BULLSEYE 058146", expected: &brevity.DeclareRequest{ Callsign: "1 4 0", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(58*unit.Degree), 146*unit.NauticalMile, ), @@ -271,7 +271,7 @@ func TestParserDeclare(t *testing.T) { text: TestCallsign + ", Thunder 1-1, declare 17631.", expected: &brevity.DeclareRequest{ Callsign: "thunder 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(176*unit.Degree), 31*unit.NauticalMile, ), @@ -283,7 +283,7 @@ func TestParserDeclare(t *testing.T) { text: TestCallsign + ", Thunder 1-1, declare 177.29.", expected: &brevity.DeclareRequest{ Callsign: "thunder 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(177*unit.Degree), 29*unit.NauticalMile, ), @@ -295,7 +295,7 @@ func TestParserDeclare(t *testing.T) { text: TestCallsign + " who is saying 11 requests declare 25545.", expected: &brevity.DeclareRequest{ Callsign: "who is saying 1 1", - Bullseye: *brevity.NewBullseye( + Bullseye: brevity.NewBullseye( bearings.NewMagneticBearing(255*unit.Degree), 45*unit.NauticalMile, ), @@ -304,7 +304,7 @@ func TestParserDeclare(t *testing.T) { }, }, } - runParserTestCases(t, New(TestCallsign, true), testCases, func(t *testing.T, test parserTestCase, request any) { + runParserTestCases(t, New(TestCallsign, []string{}, true), testCases, func(t *testing.T, test parserTestCase, request any) { t.Helper() expected := test.expected.(*brevity.DeclareRequest) actual := request.(*brevity.DeclareRequest) @@ -346,7 +346,7 @@ func TestParserDeclareUnable(t *testing.T) { }, } - runParserTestCases(t, New(TestCallsign, true), testCases, func(t *testing.T, test parserTestCase, request any) { + runParserTestCases(t, New(TestCallsign, []string{}, true), testCases, func(t *testing.T, test parserTestCase, request any) { t.Helper() expected := test.expected.(*brevity.UnableToUnderstandRequest) assert.NotNil(t, expected) diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go index 6261ea94..dff03905 100644 --- a/pkg/parser/parser.go +++ b/pkg/parser/parser.go @@ -15,13 +15,15 @@ import ( type Parser struct { gciCallsign string enableTextLogging bool + locations []string } // New creates a new parser. -func New(callsign string, enableTextLogging bool) *Parser { +func New(callsign string, locations []string, enableTextLogging bool) *Parser { return &Parser{ gciCallsign: strings.ReplaceAll(callsign, " ", ""), enableTextLogging: enableTextLogging, + locations: locations, } } @@ -39,9 +41,11 @@ const ( snaplock string = "snaplock" spiked string = "spiked" tripwire string = "tripwire" + vector string = "vector" + tanker string = "tanker" ) -var requestWords = []string{radioCheck, alphaCheck, bogeyDope, declare, picture, spiked, snaplock, tripwire, shopping} +var requestWords = []string{radioCheck, alphaCheck, bogeyDope, declare, picture, spiked, snaplock, tripwire, shopping, vector} func (p *Parser) findGCICallsign(fields []string) (callsign string, rest string, found bool) { for i := range fields { @@ -215,6 +219,10 @@ func (p *Parser) Parse(tx string) any { if request, ok := parseSnaplock(pilotCallsign, scanner); ok { return request } + case vector: + if request, ok := parseVector(pilotCallsign, p.locations, scanner); ok { + return request + } } logger.Debug().Msg("unrecognized request") diff --git a/pkg/parser/parser_test.go b/pkg/parser/parser_test.go index 66a51110..31cff345 100644 --- a/pkg/parser/parser_test.go +++ b/pkg/parser/parser_test.go @@ -53,7 +53,7 @@ func TestParserSadPaths(t *testing.T) { } runParserTestCases( t, - New(TestCallsign, true), + New(TestCallsign, []string{}, true), testCases, func(t *testing.T, test parserTestCase, request any) { t.Helper() diff --git a/pkg/parser/picture_test.go b/pkg/parser/picture_test.go index e8e947a1..e38c0da9 100644 --- a/pkg/parser/picture_test.go +++ b/pkg/parser/picture_test.go @@ -29,7 +29,7 @@ func TestParserPicture(t *testing.T) { }, }, } - runParserTestCases(t, New(TestCallsign, true), testCases, func(t *testing.T, test parserTestCase, request any) { + runParserTestCases(t, New(TestCallsign, []string{}, true), testCases, func(t *testing.T, test parserTestCase, request any) { t.Helper() expected := test.expected.(*brevity.PictureRequest) actual := request.(*brevity.PictureRequest) diff --git a/pkg/parser/radiocheck_test.go b/pkg/parser/radiocheck_test.go index 9c0954a2..d7faaa9e 100644 --- a/pkg/parser/radiocheck_test.go +++ b/pkg/parser/radiocheck_test.go @@ -83,7 +83,7 @@ func TestParserRadioCheck(t *testing.T) { }, }, } - runParserTestCases(t, New(TestCallsign, true), testCases, func(t *testing.T, test parserTestCase, request any) { + runParserTestCases(t, New(TestCallsign, []string{}, true), testCases, func(t *testing.T, test parserTestCase, request any) { t.Helper() expected := test.expected.(*brevity.RadioCheckRequest) actual := request.(*brevity.RadioCheckRequest) diff --git a/pkg/parser/snaplock_test.go b/pkg/parser/snaplock_test.go index be004fe8..12c2607b 100644 --- a/pkg/parser/snaplock_test.go +++ b/pkg/parser/snaplock_test.go @@ -68,7 +68,7 @@ func TestParserSnaplock(t *testing.T) { }, }, } - runParserTestCases(t, New(TestCallsign, true), testCases, func(t *testing.T, test parserTestCase, request any) { + runParserTestCases(t, New(TestCallsign, []string{}, true), testCases, func(t *testing.T, test parserTestCase, request any) { t.Helper() expected := test.expected.(*brevity.SnaplockRequest) actual := request.(*brevity.SnaplockRequest) diff --git a/pkg/parser/spatial.go b/pkg/parser/spatial.go index c9a17d8c..070bd4c6 100644 --- a/pkg/parser/spatial.go +++ b/pkg/parser/spatial.go @@ -12,7 +12,7 @@ import ( var bullseyeWords = []string{"bullseye", "bulls"} -func parseBullseye(scanner *bufio.Scanner) *brevity.Bullseye { +func parseBullseye(scanner *bufio.Scanner) brevity.Bullseye { if !skipWords(scanner, bullseyeWords...) { return nil } diff --git a/pkg/parser/spiked_test.go b/pkg/parser/spiked_test.go index 6fb40c26..f13b8353 100644 --- a/pkg/parser/spiked_test.go +++ b/pkg/parser/spiked_test.go @@ -34,7 +34,7 @@ func TestParserSpiked(t *testing.T) { }, }, } - runParserTestCases(t, New(TestCallsign, true), testCases, func(t *testing.T, test parserTestCase, request any) { + runParserTestCases(t, New(TestCallsign, []string{}, true), testCases, func(t *testing.T, test parserTestCase, request any) { t.Helper() expected := test.expected.(*brevity.SpikedRequest) actual := request.(*brevity.SpikedRequest) diff --git a/pkg/parser/vector.go b/pkg/parser/vector.go new file mode 100644 index 00000000..725356c7 --- /dev/null +++ b/pkg/parser/vector.go @@ -0,0 +1,37 @@ +package parser + +import ( + "bufio" + "strings" + + "github.com/dharmab/skyeye/pkg/brevity" +) + +const ( + LocationTanker = "tanker" +) + +func parseVector(callsign string, locations []string, scanner *bufio.Scanner) (*brevity.VectorRequest, bool) { + request := &brevity.VectorRequest{Callsign: callsign} + locations = append(locations, LocationTanker) + + var words []string + for scanner.Scan() { + word := strings.ToLower(scanner.Text()) + words = append(words, word) + } + + for i := range words { + for j := i; j < len(words); j++ { + sequence := strings.Join(words[i:j+1], " ") + for _, location := range locations { + if isSimilar(sequence, location) { + request.Location = location + return request, true + } + } + } + } + + return nil, false +} diff --git a/pkg/parser/vector_test.go b/pkg/parser/vector_test.go new file mode 100644 index 00000000..2c050de6 --- /dev/null +++ b/pkg/parser/vector_test.go @@ -0,0 +1,44 @@ +package parser + +import ( + "testing" + + "github.com/dharmab/skyeye/pkg/brevity" + "github.com/stretchr/testify/assert" +) + +func TestParserVector(t *testing.T) { + t.Parallel() + locations := []string{"home plate", "rock"} + testCases := []parserTestCase{ + { + text: "Anyface, Eagle 1, vector to home plate", + expected: &brevity.VectorRequest{ + Callsign: "eagle 1", + Location: "home plate", + }, + }, + { + text: "Anyface, eagle 1, vector rock", + expected: &brevity.VectorRequest{ + Callsign: "eagle 1", + Location: "rock", + }, + }, + { + text: "Anyface, eagle 1, vector to nearest tanker", + expected: &brevity.VectorRequest{ + Callsign: "eagle 1", + Location: "tanker", + }, + }, + } + parser := New(TestCallsign, locations, true) + runParserTestCases(t, parser, testCases, func(t *testing.T, test parserTestCase, request any) { + t.Helper() + expected := test.expected.(*brevity.VectorRequest) + actual := request.(*brevity.VectorRequest) + assert.Equal(t, expected.Callsign, actual.Callsign) + assert.Equal(t, expected.Location, actual.Location) + }) +} diff --git a/pkg/radar/group.go b/pkg/radar/group.go index 1d39c9e6..d784bbff 100644 --- a/pkg/radar/group.go +++ b/pkg/radar/group.go @@ -46,7 +46,7 @@ func (g *group) Contacts() int { } // Bullseye implements [brevity.Group.Bullseye]. -func (g *group) Bullseye() *brevity.Bullseye { +func (g *group) Bullseye() brevity.Bullseye { if g.bullseye == nil { return nil } diff --git a/pkg/trackfiles/trackfile.go b/pkg/trackfiles/trackfile.go index 8632f345..42582cb1 100644 --- a/pkg/trackfiles/trackfile.go +++ b/pkg/trackfiles/trackfile.go @@ -100,7 +100,7 @@ func (t *Trackfile) Bullseye(bullseye orb.Point) brevity.Bullseye { bearing := spatial.TrueBearing(bullseye, latest.Point).Magnetic(declination) log.Debug().Float64("bearing", bearing.Degrees()).Msg("calculated bullseye bearing for group") distance := spatial.Distance(bullseye, latest.Point) - return *brevity.NewBullseye(bearing, distance) + return brevity.NewBullseye(bearing, distance) } // LastKnown returns the most recent frame in the trackfile.