From 014dd626ac36fa125f963d5c3d6d8a276de8271c Mon Sep 17 00:00:00 2001 From: Dharma Bellamkonda Date: Fri, 10 Jul 2026 16:39:55 -0600 Subject: [PATCH 1/4] Add custom aircraft encyclopedia feature Add a --aircraft-file option (YAML, and JSON since YAML is a superset) that extends or overrides SkyEye's built-in aircraft encyclopedia at startup, so community aircraft mods that SkyEye does not recognize can be categorized, threat-assessed, named on the radio, and matched to tankers. Modeled on the existing custom locations feature. Custom entries parse directly into encyclopedia.Aircraft via an unexported serializedAircraft schema; entries are validated (required ACMI short name, at least one reporting-name field, exactly one wing tag, recognized tags/fuel methods) and registered into the lookup table before the radar is constructed. An entry reusing a built-in ACMI short name overrides it, logged at INFO. Documented in docs/AIRCRAFT.md with the A-4 Skyhawk as the example. Co-Authored-By: Claude Opus 4.8 --- cmd/skyeye/main.go | 24 ++++++ docs/ADMIN.md | 4 + docs/AIRCRAFT.md | 113 ++++++++++++++++++++++++++ internal/application/app.go | 4 + internal/conf/configuration.go | 4 + pkg/encyclopedia/custom.go | 138 +++++++++++++++++++++++++++++++ pkg/encyclopedia/custom_test.go | 140 ++++++++++++++++++++++++++++++++ 7 files changed, 427 insertions(+) create mode 100644 docs/AIRCRAFT.md create mode 100644 pkg/encyclopedia/custom.go create mode 100644 pkg/encyclopedia/custom_test.go diff --git a/cmd/skyeye/main.go b/cmd/skyeye/main.go index e75c2773..77730eaa 100644 --- a/cmd/skyeye/main.go +++ b/cmd/skyeye/main.go @@ -29,6 +29,7 @@ import ( "github.com/dharmab/skyeye/internal/cli" "github.com/dharmab/skyeye/internal/conf" "github.com/dharmab/skyeye/pkg/coalitions" + "github.com/dharmab/skyeye/pkg/encyclopedia" "github.com/dharmab/skyeye/pkg/locations" "github.com/dharmab/skyeye/pkg/synthesizer/voices" "github.com/ggerganov/whisper.cpp/bindings/go/pkg/whisper" @@ -80,6 +81,7 @@ var ( exitAfter time.Duration enableTerrainDetection bool locationsFile string + aircraftFile string ) const ( @@ -162,6 +164,10 @@ func init() { if err := skyeye.MarkFlagFilename("locations-file", "json", "yaml", "yml"); err != nil { log.Fatal().Err(err).Msg("failed to mark flag as filename") } + skyeye.Flags().StringVar(&aircraftFile, "aircraft-file", "", "Path to file containing additional aircraft that extend or override the built-in encyclopedia.") + if err := skyeye.MarkFlagFilename("aircraft-file", "json", "yaml", "yml"); err != nil { + log.Fatal().Err(err).Msg("failed to mark flag as filename") + } // Tracing skyeye.Flags().BoolVar(&enableTracing, "enable-tracing", false, "Enable tracing") @@ -361,6 +367,22 @@ func loadLocations() []locations.Location { return locs } +func loadAircraft() []encyclopedia.Aircraft { + if aircraftFile == "" { + return nil + } + data, err := os.ReadFile(aircraftFile) + if err != nil { + log.Fatal().Err(err).Str("path", aircraftFile).Msg("failed to read aircraft file") + } + aircraft, err := encyclopedia.LoadCustomAircraft(data) + if err != nil { + log.Fatal().Err(err).Str("path", aircraftFile).Msg("failed to load aircraft file") + } + log.Info().Int("count", len(aircraft)).Msg("loaded custom aircraft") + return aircraft +} + func preRun(cmd *cobra.Command, _ []string) error { if err := initializeConfig(cmd); err != nil { return fmt.Errorf("failed to initialize config: %w", err) @@ -409,6 +431,7 @@ func run(_ *cobra.Command, _ []string) { recognizerLock := loadLock(recognizerLockPath) volume := loadVoiceVolume() locs := loadLocations() + customAircraft := loadAircraft() config := conf.Configuration{ ACMIFile: acmiFile, @@ -453,6 +476,7 @@ func run(_ *cobra.Command, _ []string) { GRPCAPIKey: grpcAPIKey, EnableTerrainDetection: enableTerrainDetection, Locations: locs, + CustomAircraft: customAircraft, } log.Info().Msg("starting application") diff --git a/docs/ADMIN.md b/docs/ADMIN.md index 256e5c1e..837dd456 100644 --- a/docs/ADMIN.md +++ b/docs/ADMIN.md @@ -280,6 +280,10 @@ To enable this feature, first create a webhook in your Discord server (Server Se SkyEye includes an optional feature to define custom locations that players can reference in VECTOR TO requests. This can be useful for providing navigation assistance to airbases and other points of interest. See [LOCATIONS.md](LOCATIONS.md) for a guide. +## Custom Aircraft + +SkyEye includes an optional feature to extend or override its built-in aircraft encyclopedia. This is useful for supporting community aircraft mods that SkyEye does not recognize out of the box. See [AIRCRAFT.md](AIRCRAFT.md) for a guide. + ## Autoscaling (Experimental) The included `skyeye-scaler` program is an optional autoscaler tool. It monitors a set of frequencies in SRS, and continually sends POST requests to a custom webhook. The webhook URL is defined by setting the `--webhook-url` flag or `SKYEYE_SCALER_WEBHOOK_URL` environment variable. diff --git a/docs/AIRCRAFT.md b/docs/AIRCRAFT.md new file mode 100644 index 00000000..1889dcdf --- /dev/null +++ b/docs/AIRCRAFT.md @@ -0,0 +1,113 @@ +# Custom Aircraft + +SkyEye ships with an embedded encyclopedia of aircraft data, which the +controller uses for various GCI decisions. + +You can extend this encyclopedia to add support for community mods, or new +modules which have not yet been added to SkyEye's embedded data. + +## Enabling the Feature + +Create a YAML file containing a list of one or more aircraft entries, then set the path to it +in the `aircraft-file` setting in SkyEye's configuration. + +## Aircraft Properties + +Each entry supports the following properties: + +| Property | Required | Description | +| --- | --- | --- | +| `acmi_short_name` | Yes | The name used for this aircraft in Tacview/ACMI telemetry. This must match exactly. See [Finding the ACMI short name](#finding-the-acmi-short-name). | +| `tags` | Yes | A list of tags describing the aircraft. See [Tags](#tags). | +| `nato_reporting_name` | No | The NATO reporting name, e.g. `Flanker`, `Fulcrum`. Not all aircraft have one. | +| `nickname` | No | A common nickname, e.g. `Warthog`, `Viper`, `Scooter`. Not all aircraft have one. | +| `official_name` | No | The official name, e.g. `Thunderbolt II`, `Fighting Falcon`, `Skyhawk`. Not all aircraft have one. | +| `platform_designation` | No | The platform designation, e.g. `A-10`, `F-16`, `A-4`. | +| `type_designation` | No | The specific type designation, e.g. `A-10C`, `F-16C`, `A-4E`. | +| `threat_radius_nm` | No | The threat radius in nautical miles. See [Threat radius](#threat-radius). | +| `fuel_provider` | No | The refueling method this aircraft provides as a tanker. See [Refueling](#refueling). | +| `fuel_receiver` | No | The refueling method this aircraft requires to take fuel. See [Refueling](#refueling). | + +### Reporting Name + +The name SkyEye uses to call out a contact on the radio is its **reporting name**. SkyEye derives it +from the properties above in this order of preference: `nato_reporting_name`, then `nickname`, then +`official_name`, then `platform_designation`. Every aircraft must set at least one of these four +properties so SkyEye has a name to call it. + +### Tags + +`tags` is a list of one or more of the following values: + +- `fixed-wing` — a fixed-wing aircraft. +- `rotary-wing` — a helicopter. +- `fighter` — a fighter armed with air-to-air missiles. +- `attack` — an attack aircraft with self-defense air-to-air missiles. +- `unarmed` — an aircraft with no air-to-air missiles (transports, tankers, AWACS, etc.). + +Every aircraft must have **exactly one** of `fixed-wing` or `rotary-wing`. The `fighter`, `attack`, +and `unarmed` tags affect how contacts are grouped and, when you do not set `threat_radius_nm`, the +default threat radius. + +### Threat Radius + +`threat_radius_nm` is the range, in nautical miles, at which SkyEye considers the aircraft a threat. +This property is optional. If you omit it, SkyEye picks a sensible default from the aircraft's tags. + +If you want to set it explicitly, these values are good starting points: + +- **15 NM** for aircraft armed with older semi-active radar missiles, or infrared missiles. +- **25 NM** for aircraft armed with newer semi-active missiles, or active radar missiles. +- **35 NM** for fast interceptors and advanced fighters. + +### Refueling + +`fuel_provider` and `fuel_receiver` describe aerial refueling and drive VECTOR TANKER matching. Each +accepts one of: + +- `boom` — flying boom refueling. +- `probe-and-drogue` — probe-and-drogue (basket) refueling. + +The two properties apply to different kinds of aircraft, so set only the one relevant to yours: + +- **Tanker aircraft:** set `fuel_provider` to the method the tanker dispenses. Leave `fuel_receiver` + unset. This is what marks the aircraft as a tanker for VECTOR TO TANKER commands. +- **Receiver aircraft:** set `fuel_receiver` to the method the aircraft takes fuel with, if any. Leave + `fuel_provider` unset. + +When a player asks for a vector to a tanker, SkyEye only sends them to a tanker whose `fuel_provider` +matches their own aircraft's `fuel_receiver`. + +## Finding the ACMI Short Name + +The `acmi_short_name` must match the aircraft's `ShortName` property in Tacview/ACMI telemetry +exactly. Case and punctuation matter. See the [ACMI telemetry file format reference](https://raia-software-inc.gitbook.io/tacview/technical-documentation/acmi-telemetry-file-format) +for details on the `ShortName` property. + +## Overriding Built-In Aircraft + +If an entry's `acmi_short_name` matches an aircraft already in the built-in encyclopedia, your entry +replaces the built-in one. This lets you customize the data for an aircraft that already ships with SkyEye. + +## Example + +The example below adds the A-4 Skyhawk community mod. It is an attack aircraft that uses +probe-and-drogue refueling, and its ACMI short name is `A-4E-C`. + + +```yaml +- acmi_short_name: A-4E-C + tags: + - fixed-wing + - attack + platform_designation: A-4 + type_designation: A-4E + official_name: Skyhawk + nickname: Scooter + threat_radius_nm: 15 + fuel_receiver: probe-and-drogue +``` + +With this file loaded, SkyEye recognizes the A-4, calls it a "Scooter" (the `nickname` is preferred +over the `official_name`), treats it as an attack aircraft with a 15 NM threat radius, and sends it +to probe-and-drogue tankers. diff --git a/internal/application/app.go b/internal/application/app.go index 2da0081d..e8ca4742 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" + "github.com/dharmab/skyeye/pkg/encyclopedia" "github.com/dharmab/skyeye/pkg/parser" "github.com/dharmab/skyeye/pkg/radar" "github.com/dharmab/skyeye/pkg/recognizer" @@ -178,6 +179,9 @@ func NewApplication(config conf.Configuration) (*Application, error) { log.Info().Msg("constructing request parser") requestParser := parser.New(config.Callsign, locationNames, config.EnableTranscriptionLogging) + // Register custom aircraft into the encyclopedia before the radar, its first consumer, is built. + encyclopedia.AddCustomAircraft(config.CustomAircraft) + log.Info().Msg("constructing radar scope") // When threat monitoring requires SRS, the radar uses the SRS client to restrict the diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 91626752..37f3b248 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -4,6 +4,7 @@ import ( "time" "github.com/dharmab/skyeye/pkg/coalitions" + "github.com/dharmab/skyeye/pkg/encyclopedia" "github.com/dharmab/skyeye/pkg/locations" "github.com/dharmab/skyeye/pkg/simpleradio" "github.com/dharmab/skyeye/pkg/synthesizer/voices" @@ -102,6 +103,9 @@ type Configuration struct { ThreatMonitoringRequiresSRS bool // Locations is a slice of named locations that can be referenced in VECTOR calls. Locations []locations.Location + // CustomAircraft is a slice of user-provided aircraft entries that extend or override the + // built-in encyclopedia. Registered into the encyclopedia at application startup. + CustomAircraft []encyclopedia.Aircraft // EnableTracing controls whether to publish traces EnableTracing bool // DiscordWebhookID is the ID of the Discord webhook diff --git a/pkg/encyclopedia/custom.go b/pkg/encyclopedia/custom.go new file mode 100644 index 00000000..d2b18771 --- /dev/null +++ b/pkg/encyclopedia/custom.go @@ -0,0 +1,138 @@ +package encyclopedia + +import ( + "errors" + "fmt" + "strings" + + "github.com/dharmab/collections/sets" + "github.com/martinlindhe/unit" + "github.com/rs/zerolog/log" + "gopkg.in/yaml.v3" +) + +// serializedAircraft is the on-disk representation of an Aircraft. It exists only to translate +// between the human-friendly string schema and the Aircraft type's unexported enum fields; it is +// never exposed outside this package. +type serializedAircraft struct { + ACMIShortName string `yaml:"acmi_short_name"` + Tags []string `yaml:"tags"` + PlatformDesignation string `yaml:"platform_designation"` + TypeDesignation string `yaml:"type_designation"` + NATOReportingName string `yaml:"nato_reporting_name"` + OfficialName string `yaml:"official_name"` + Nickname string `yaml:"nickname"` + ThreatRadiusNM float64 `yaml:"threat_radius_nm"` + FuelProvider string `yaml:"fuel_provider"` + FuelReceiver string `yaml:"fuel_receiver"` +} + +// tagsByName maps the tag names accepted in custom aircraft files to their AircraftTag values. +var tagsByName = map[string]AircraftTag{ + "fixed-wing": FixedWing, + "rotary-wing": RotaryWing, + "unarmed": Unarmed, + "fighter": Fighter, + "attack": Attack, +} + +// refuelingByName maps the refueling method names accepted in custom aircraft files to their +// AirRefuelingMethod values. An empty string or "none" maps to NoAirRefueling. +var refuelingByName = map[string]AirRefuelingMethod{ + "": NoAirRefueling, + "none": NoAirRefueling, + "boom": FlyingBoom, + "probe-and-drogue": ProbeAndDrogue, +} + +// toAircraft validates the string schema and converts it into an Aircraft. +func (s serializedAircraft) toAircraft() (Aircraft, error) { + if strings.TrimSpace(s.ACMIShortName) == "" { + return Aircraft{}, errors.New("aircraft must have a non-empty acmi_short_name") + } + + // SkyEye derives an aircraft's reporting name from these fields, so at least one is required or + // the GCI would have nothing to call the contact. + if strings.TrimSpace(s.NATOReportingName) == "" && + strings.TrimSpace(s.Nickname) == "" && + strings.TrimSpace(s.OfficialName) == "" && + strings.TrimSpace(s.PlatformDesignation) == "" { + return Aircraft{}, fmt.Errorf("aircraft %q must have at least one of nato_reporting_name, nickname, official_name, or platform_designation", s.ACMIShortName) + } + + if len(s.Tags) == 0 { + return Aircraft{}, fmt.Errorf("aircraft %q must have at least one tag", s.ACMIShortName) + } + tags := sets.New[AircraftTag]() + wingCount := 0 + for _, name := range s.Tags { + tag, ok := tagsByName[strings.ToLower(strings.TrimSpace(name))] + if !ok { + return Aircraft{}, fmt.Errorf("aircraft %q has unrecognized tag %q", s.ACMIShortName, name) + } + if tag == FixedWing || tag == RotaryWing { + wingCount++ + } + sets.Add(tags, tag) + } + if wingCount != 1 { + return Aircraft{}, fmt.Errorf("aircraft %q must have exactly one of the tags fixed-wing or rotary-wing", s.ACMIShortName) + } + + if s.ThreatRadiusNM < 0 { + return Aircraft{}, fmt.Errorf("aircraft %q has negative threat_radius_nm %v", s.ACMIShortName, s.ThreatRadiusNM) + } + + fuelProvider, ok := refuelingByName[strings.ToLower(strings.TrimSpace(s.FuelProvider))] + if !ok { + return Aircraft{}, fmt.Errorf("aircraft %q has unrecognized fuel_provider %q", s.ACMIShortName, s.FuelProvider) + } + fuelReceiver, ok := refuelingByName[strings.ToLower(strings.TrimSpace(s.FuelReceiver))] + if !ok { + return Aircraft{}, fmt.Errorf("aircraft %q has unrecognized fuel_receiver %q", s.ACMIShortName, s.FuelReceiver) + } + + return Aircraft{ + ACMIShortName: s.ACMIShortName, + tags: tags, + PlatformDesignation: s.PlatformDesignation, + TypeDesignation: s.TypeDesignation, + NATOReportingName: s.NATOReportingName, + OfficialName: s.OfficialName, + Nickname: s.Nickname, + threatRadius: unit.Length(s.ThreatRadiusNM) * unit.NauticalMile, + fuelProvider: fuelProvider, + fuelReceiver: fuelReceiver, + }, nil +} + +// LoadCustomAircraft parses custom aircraft data. YAML is a superset of JSON, so the data may be in +// either format. Each entry is validated during conversion. +func LoadCustomAircraft(data []byte) ([]Aircraft, error) { + var serialized []serializedAircraft + if err := yaml.Unmarshal(data, &serialized); err != nil { + return nil, fmt.Errorf("failed to parse custom aircraft file: %w", err) + } + aircraft := make([]Aircraft, 0, len(serialized)) + for _, s := range serialized { + a, err := s.toAircraft() + if err != nil { + return nil, err + } + aircraft = append(aircraft, a) + } + return aircraft, nil +} + +// AddCustomAircraft registers custom aircraft into the encyclopedia, keyed by ACMI short name. +// An entry whose ACMI short name matches a built-in or previously added entry overrides it. +func AddCustomAircraft(aircraft []Aircraft) { + for _, data := range aircraft { + event := log.Info().Str("aircraft", data.ACMIShortName) + if _, exists := aircraftDataLUT[data.ACMIShortName]; exists { + event = event.Bool("override", true) + } + aircraftDataLUT[data.ACMIShortName] = data + event.Msg("loaded custom aircraft into encyclopedia") + } +} diff --git a/pkg/encyclopedia/custom_test.go b/pkg/encyclopedia/custom_test.go new file mode 100644 index 00000000..8f996da2 --- /dev/null +++ b/pkg/encyclopedia/custom_test.go @@ -0,0 +1,140 @@ +package encyclopedia + +import ( + "testing" + + "github.com/dharmab/skyeye/pkg/brevity" + "github.com/martinlindhe/unit" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const a4YAML = ` +- acmi_short_name: A-4E-C + tags: [fixed-wing, attack] + platform_designation: A-4 + type_designation: A-4E + official_name: Skyhawk + nickname: Scooter + threat_radius_nm: 15 + fuel_receiver: probe-and-drogue +` + +const a4JSON = `[ + { + "acmi_short_name": "A-4E-C", + "tags": ["fixed-wing", "attack"], + "platform_designation": "A-4", + "type_designation": "A-4E", + "official_name": "Skyhawk", + "nickname": "Scooter", + "threat_radius_nm": 15, + "fuel_receiver": "probe-and-drogue" + } +]` + +// assertIsA4 checks that the parsed aircraft matches the expected A-4 Skyhawk fields. +func assertIsA4(t *testing.T, a Aircraft) { + t.Helper() + assert.Equal(t, "A-4E-C", a.ACMIShortName) + assert.Equal(t, "A-4", a.PlatformDesignation) + assert.Equal(t, "A-4E", a.TypeDesignation) + assert.Equal(t, "Skyhawk", a.OfficialName) + assert.Equal(t, "Scooter", a.Nickname) + assert.True(t, a.HasTag(FixedWing)) + assert.True(t, a.HasTag(Attack)) + assert.Equal(t, brevity.FixedWing, a.Category()) + assert.InDelta(t, float64(15*unit.NauticalMile), float64(a.ThreatRadius()), 0.001) + assert.Equal(t, ProbeAndDrogue, a.FuelReceiver()) + assert.Equal(t, NoAirRefueling, a.FuelProvider()) +} + +func TestLoadCustomAircraftJSON(t *testing.T) { + t.Parallel() + aircraft, err := LoadCustomAircraft([]byte(a4JSON)) + require.NoError(t, err) + require.Len(t, aircraft, 1) + assertIsA4(t, aircraft[0]) +} + +func TestLoadCustomAircraftYAML(t *testing.T) { + t.Parallel() + aircraft, err := LoadCustomAircraft([]byte(a4YAML)) + require.NoError(t, err) + require.Len(t, aircraft, 1) + assertIsA4(t, aircraft[0]) +} + +func TestLoadCustomAircraftTankerProvider(t *testing.T) { + t.Parallel() + data := `[{"acmi_short_name":"KC-Custom","tags":["fixed-wing","unarmed"],"platform_designation":"KC-Custom","fuel_provider":"boom"}]` + aircraft, err := LoadCustomAircraft([]byte(data)) + require.NoError(t, err) + require.Len(t, aircraft, 1) + assert.Equal(t, FlyingBoom, aircraft[0].FuelProvider()) + assert.Equal(t, NoAirRefueling, aircraft[0].FuelReceiver()) +} + +func TestLoadCustomAircraftThreatRadiusDefault(t *testing.T) { + t.Parallel() + // Omitting threat_radius_nm on a fighter falls back to the tag-based default. + data := `[{"acmi_short_name":"MiG-Custom","tags":["fixed-wing","fighter"],"nato_reporting_name":"Fulcrum"}]` + aircraft, err := LoadCustomAircraft([]byte(data)) + require.NoError(t, err) + require.Len(t, aircraft, 1) + assert.InDelta(t, float64(SAR2AR1Threat), float64(aircraft[0].ThreatRadius()), 0.001) +} + +func TestLoadCustomAircraftErrors(t *testing.T) { + t.Parallel() + tests := []struct { + name string + data string + }{ + {name: "not json or yaml", data: "not valid [[["}, + {name: "missing acmi short name", data: `[{"tags":["fixed-wing"],"nickname":"X"}]`}, + {name: "no reporting name", data: `[{"acmi_short_name":"X","tags":["fixed-wing"]}]`}, + {name: "no tags", data: `[{"acmi_short_name":"X","nickname":"X"}]`}, + {name: "unrecognized tag", data: `[{"acmi_short_name":"X","nickname":"X","tags":["spaceship"]}]`}, + {name: "no wing tag", data: `[{"acmi_short_name":"X","nickname":"X","tags":["attack"]}]`}, + {name: "two wing tags", data: `[{"acmi_short_name":"X","nickname":"X","tags":["fixed-wing","rotary-wing"]}]`}, + {name: "negative threat radius", data: `[{"acmi_short_name":"X","nickname":"X","tags":["fixed-wing"],"threat_radius_nm":-5}]`}, + {name: "bad fuel provider", data: `[{"acmi_short_name":"X","nickname":"X","tags":["fixed-wing"],"fuel_provider":"laser"}]`}, + {name: "bad fuel receiver", data: `[{"acmi_short_name":"X","nickname":"X","tags":["fixed-wing"],"fuel_receiver":"laser"}]`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + _, err := LoadCustomAircraft([]byte(test.data)) + require.Error(t, err) + }) + } +} + +//nolint:paralleltest // mutates the package-level aircraftDataLUT, so it must not run concurrently with map readers +func TestAddCustomAircraftOverride(t *testing.T) { + name := "TEST-OVERRIDE-JET" + require.NotContains(t, aircraftDataLUT, name) + t.Cleanup(func() { delete(aircraftDataLUT, name) }) + + data := `[{"acmi_short_name":"TEST-OVERRIDE-JET","tags":["fixed-wing","fighter"],"official_name":"Testbird","threat_radius_nm":40}]` + aircraft, err := LoadCustomAircraft([]byte(data)) + require.NoError(t, err) + AddCustomAircraft(aircraft) + + got, ok := GetAircraftData(name) + require.True(t, ok) + assert.Equal(t, "Testbird", got.OfficialName) + assert.InDelta(t, float64(40*unit.NauticalMile), float64(got.ThreatRadius()), 0.001) + + // A second entry with the same ACMI short name overrides the first. + override := `[{"acmi_short_name":"TEST-OVERRIDE-JET","tags":["fixed-wing","attack"],"official_name":"Override","threat_radius_nm":10}]` + overrideAircraft, err := LoadCustomAircraft([]byte(override)) + require.NoError(t, err) + AddCustomAircraft(overrideAircraft) + + got, ok = GetAircraftData(name) + require.True(t, ok) + assert.Equal(t, "Override", got.OfficialName) + assert.InDelta(t, float64(10*unit.NauticalMile), float64(got.ThreatRadius()), 0.001) +} From f3e35b91ca54022fe6dfd33284bfe2971808a2b8 Mon Sep 17 00:00:00 2001 From: Dharma Bellamkonda Date: Fri, 10 Jul 2026 16:46:35 -0600 Subject: [PATCH 2/4] docs: inline ACMI short name reference into properties table Co-Authored-By: Claude Opus 4.8 --- docs/AIRCRAFT.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/AIRCRAFT.md b/docs/AIRCRAFT.md index 1889dcdf..52885397 100644 --- a/docs/AIRCRAFT.md +++ b/docs/AIRCRAFT.md @@ -17,7 +17,7 @@ Each entry supports the following properties: | Property | Required | Description | | --- | --- | --- | -| `acmi_short_name` | Yes | The name used for this aircraft in Tacview/ACMI telemetry. This must match exactly. See [Finding the ACMI short name](#finding-the-acmi-short-name). | +| `acmi_short_name` | Yes | The aircraft's `ShortName` in [Tacview/ACMI telemetry](https://raia-software-inc.gitbook.io/tacview/technical-documentation/acmi-telemetry-file-format). Must match exactly; case and punctuation matter. | | `tags` | Yes | A list of tags describing the aircraft. See [Tags](#tags). | | `nato_reporting_name` | No | The NATO reporting name, e.g. `Flanker`, `Fulcrum`. Not all aircraft have one. | | `nickname` | No | A common nickname, e.g. `Warthog`, `Viper`, `Scooter`. Not all aircraft have one. | @@ -78,12 +78,6 @@ The two properties apply to different kinds of aircraft, so set only the one rel When a player asks for a vector to a tanker, SkyEye only sends them to a tanker whose `fuel_provider` matches their own aircraft's `fuel_receiver`. -## Finding the ACMI Short Name - -The `acmi_short_name` must match the aircraft's `ShortName` property in Tacview/ACMI telemetry -exactly. Case and punctuation matter. See the [ACMI telemetry file format reference](https://raia-software-inc.gitbook.io/tacview/technical-documentation/acmi-telemetry-file-format) -for details on the `ShortName` property. - ## Overriding Built-In Aircraft If an entry's `acmi_short_name` matches an aircraft already in the built-in encyclopedia, your entry From ae2cee5f2e6851a733652caddc8f9b11a1c77999 Mon Sep 17 00:00:00 2001 From: Dharma Bellamkonda Date: Fri, 10 Jul 2026 16:46:56 -0600 Subject: [PATCH 3/4] docs: fix grammar in acmi_short_name description Co-Authored-By: Claude Opus 4.8 --- docs/AIRCRAFT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/AIRCRAFT.md b/docs/AIRCRAFT.md index 52885397..b79e6c91 100644 --- a/docs/AIRCRAFT.md +++ b/docs/AIRCRAFT.md @@ -17,7 +17,7 @@ Each entry supports the following properties: | Property | Required | Description | | --- | --- | --- | -| `acmi_short_name` | Yes | The aircraft's `ShortName` in [Tacview/ACMI telemetry](https://raia-software-inc.gitbook.io/tacview/technical-documentation/acmi-telemetry-file-format). Must match exactly; case and punctuation matter. | +| `acmi_short_name` | Yes | The aircraft's `ShortName` in [Tacview/ACMI telemetry](https://raia-software-inc.gitbook.io/tacview/technical-documentation/acmi-telemetry-file-format). It must match exactly, including case and punctuation. | | `tags` | Yes | A list of tags describing the aircraft. See [Tags](#tags). | | `nato_reporting_name` | No | The NATO reporting name, e.g. `Flanker`, `Fulcrum`. Not all aircraft have one. | | `nickname` | No | A common nickname, e.g. `Warthog`, `Viper`, `Scooter`. Not all aircraft have one. | From 13f23496e61a119dc6fc41899d952419207f21e7 Mon Sep 17 00:00:00 2001 From: Dharma Bellamkonda Date: Fri, 10 Jul 2026 16:48:20 -0600 Subject: [PATCH 4/4] docs: remove parenthetical from A-4 example Co-Authored-By: Claude Opus 4.8 --- docs/AIRCRAFT.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/AIRCRAFT.md b/docs/AIRCRAFT.md index b79e6c91..3af5fdbc 100644 --- a/docs/AIRCRAFT.md +++ b/docs/AIRCRAFT.md @@ -102,6 +102,5 @@ probe-and-drogue refueling, and its ACMI short name is `A-4E-C`. fuel_receiver: probe-and-drogue ``` -With this file loaded, SkyEye recognizes the A-4, calls it a "Scooter" (the `nickname` is preferred -over the `official_name`), treats it as an attack aircraft with a 15 NM threat radius, and sends it -to probe-and-drogue tankers. +With this file loaded, SkyEye recognizes the A-4, calls it a "Scooter", treats it as an attack +aircraft with a 15 NM threat radius, and sends it to probe-and-drogue tankers.