Skip to content

Commit 51aee48

Browse files
dharmabclaude
andauthored
Add custom aircraft encyclopedia feature (#707)
## Summary Adds a `--aircraft-file` option (`aircraft-file` config key) that lets server admins extend or override SkyEye's built-in aircraft encyclopedia with a user-supplied YAML file. This makes community aircraft mods that SkyEye does not recognize work correctly — they can be categorized, threat-assessed, named on the radio, and matched to compatible tankers. Modeled on the existing custom locations feature. ## How it works - New `pkg/encyclopedia/custom.go`: an unexported `serializedAircraft` schema parses directly into `encyclopedia.Aircraft`. `LoadCustomAircraft` reads YAML (a superset of JSON, so `.json` files also work), and `AddCustomAircraft` registers entries into the lookup table. - Validation: required `acmi_short_name`, at least one reporting-name field (`nato_reporting_name`/`nickname`/`official_name`/`platform_designation`), exactly one wing tag, and recognized tag/fuel values. - Wiring: `--aircraft-file` flag in `cmd/skyeye`, `CustomAircraft` field in `conf.Configuration`, and registration in `application.NewApplication` just before the radar (its first consumer) is built. - Overrides: an entry reusing a built-in ACMI short name replaces it, logged at INFO. Each loaded aircraft logs one INFO line. ## Docs `docs/AIRCRAFT.md` documents the schema, tags, threat radius, refueling, and how to find the ACMI short name, using the A-4 Skyhawk as a worked example. Cross-linked from `docs/ADMIN.md`. ## Testing - `make test` — passes, including new `pkg/encyclopedia/custom_test.go` (YAML/JSON parsing, validation errors, tag/fuel mapping, override behavior). - `make lint vet fix format` — clean. - `make skyeye` — builds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 716bf5f commit 51aee48

7 files changed

Lines changed: 420 additions & 0 deletions

File tree

cmd/skyeye/main.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/dharmab/skyeye/internal/cli"
3030
"github.com/dharmab/skyeye/internal/conf"
3131
"github.com/dharmab/skyeye/pkg/coalitions"
32+
"github.com/dharmab/skyeye/pkg/encyclopedia"
3233
"github.com/dharmab/skyeye/pkg/locations"
3334
"github.com/dharmab/skyeye/pkg/synthesizer/voices"
3435
"github.com/ggerganov/whisper.cpp/bindings/go/pkg/whisper"
@@ -80,6 +81,7 @@ var (
8081
exitAfter time.Duration
8182
enableTerrainDetection bool
8283
locationsFile string
84+
aircraftFile string
8385
)
8486

8587
const (
@@ -162,6 +164,10 @@ func init() {
162164
if err := skyeye.MarkFlagFilename("locations-file", "json", "yaml", "yml"); err != nil {
163165
log.Fatal().Err(err).Msg("failed to mark flag as filename")
164166
}
167+
skyeye.Flags().StringVar(&aircraftFile, "aircraft-file", "", "Path to file containing additional aircraft that extend or override the built-in encyclopedia.")
168+
if err := skyeye.MarkFlagFilename("aircraft-file", "json", "yaml", "yml"); err != nil {
169+
log.Fatal().Err(err).Msg("failed to mark flag as filename")
170+
}
165171

166172
// Tracing
167173
skyeye.Flags().BoolVar(&enableTracing, "enable-tracing", false, "Enable tracing")
@@ -361,6 +367,22 @@ func loadLocations() []locations.Location {
361367
return locs
362368
}
363369

370+
func loadAircraft() []encyclopedia.Aircraft {
371+
if aircraftFile == "" {
372+
return nil
373+
}
374+
data, err := os.ReadFile(aircraftFile)
375+
if err != nil {
376+
log.Fatal().Err(err).Str("path", aircraftFile).Msg("failed to read aircraft file")
377+
}
378+
aircraft, err := encyclopedia.LoadCustomAircraft(data)
379+
if err != nil {
380+
log.Fatal().Err(err).Str("path", aircraftFile).Msg("failed to load aircraft file")
381+
}
382+
log.Info().Int("count", len(aircraft)).Msg("loaded custom aircraft")
383+
return aircraft
384+
}
385+
364386
func preRun(cmd *cobra.Command, _ []string) error {
365387
if err := initializeConfig(cmd); err != nil {
366388
return fmt.Errorf("failed to initialize config: %w", err)
@@ -409,6 +431,7 @@ func run(_ *cobra.Command, _ []string) {
409431
recognizerLock := loadLock(recognizerLockPath)
410432
volume := loadVoiceVolume()
411433
locs := loadLocations()
434+
customAircraft := loadAircraft()
412435

413436
config := conf.Configuration{
414437
ACMIFile: acmiFile,
@@ -453,6 +476,7 @@ func run(_ *cobra.Command, _ []string) {
453476
GRPCAPIKey: grpcAPIKey,
454477
EnableTerrainDetection: enableTerrainDetection,
455478
Locations: locs,
479+
CustomAircraft: customAircraft,
456480
}
457481

458482
log.Info().Msg("starting application")

docs/ADMIN.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,10 @@ To enable this feature, first create a webhook in your Discord server (Server Se
280280

281281
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.
282282

283+
## Custom Aircraft
284+
285+
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.
286+
283287
## Autoscaling (Experimental)
284288

285289
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.

docs/AIRCRAFT.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Custom Aircraft
2+
3+
SkyEye ships with an embedded encyclopedia of aircraft data, which the
4+
controller uses for various GCI decisions.
5+
6+
You can extend this encyclopedia to add support for community mods, or new
7+
modules which have not yet been added to SkyEye's embedded data.
8+
9+
## Enabling the Feature
10+
11+
Create a YAML file containing a list of one or more aircraft entries, then set the path to it
12+
in the `aircraft-file` setting in SkyEye's configuration.
13+
14+
## Aircraft Properties
15+
16+
Each entry supports the following properties:
17+
18+
| Property | Required | Description |
19+
| --- | --- | --- |
20+
| `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. |
21+
| `tags` | Yes | A list of tags describing the aircraft. See [Tags](#tags). |
22+
| `nato_reporting_name` | No | The NATO reporting name, e.g. `Flanker`, `Fulcrum`. Not all aircraft have one. |
23+
| `nickname` | No | A common nickname, e.g. `Warthog`, `Viper`, `Scooter`. Not all aircraft have one. |
24+
| `official_name` | No | The official name, e.g. `Thunderbolt II`, `Fighting Falcon`, `Skyhawk`. Not all aircraft have one. |
25+
| `platform_designation` | No | The platform designation, e.g. `A-10`, `F-16`, `A-4`. |
26+
| `type_designation` | No | The specific type designation, e.g. `A-10C`, `F-16C`, `A-4E`. |
27+
| `threat_radius_nm` | No | The threat radius in nautical miles. See [Threat radius](#threat-radius). |
28+
| `fuel_provider` | No | The refueling method this aircraft provides as a tanker. See [Refueling](#refueling). |
29+
| `fuel_receiver` | No | The refueling method this aircraft requires to take fuel. See [Refueling](#refueling). |
30+
31+
### Reporting Name
32+
33+
The name SkyEye uses to call out a contact on the radio is its **reporting name**. SkyEye derives it
34+
from the properties above in this order of preference: `nato_reporting_name`, then `nickname`, then
35+
`official_name`, then `platform_designation`. Every aircraft must set at least one of these four
36+
properties so SkyEye has a name to call it.
37+
38+
### Tags
39+
40+
`tags` is a list of one or more of the following values:
41+
42+
- `fixed-wing` — a fixed-wing aircraft.
43+
- `rotary-wing` — a helicopter.
44+
- `fighter` — a fighter armed with air-to-air missiles.
45+
- `attack` — an attack aircraft with self-defense air-to-air missiles.
46+
- `unarmed` — an aircraft with no air-to-air missiles (transports, tankers, AWACS, etc.).
47+
48+
Every aircraft must have **exactly one** of `fixed-wing` or `rotary-wing`. The `fighter`, `attack`,
49+
and `unarmed` tags affect how contacts are grouped and, when you do not set `threat_radius_nm`, the
50+
default threat radius.
51+
52+
### Threat Radius
53+
54+
`threat_radius_nm` is the range, in nautical miles, at which SkyEye considers the aircraft a threat.
55+
This property is optional. If you omit it, SkyEye picks a sensible default from the aircraft's tags.
56+
57+
If you want to set it explicitly, these values are good starting points:
58+
59+
- **15 NM** for aircraft armed with older semi-active radar missiles, or infrared missiles.
60+
- **25 NM** for aircraft armed with newer semi-active missiles, or active radar missiles.
61+
- **35 NM** for fast interceptors and advanced fighters.
62+
63+
### Refueling
64+
65+
`fuel_provider` and `fuel_receiver` describe aerial refueling and drive VECTOR TANKER matching. Each
66+
accepts one of:
67+
68+
- `boom` — flying boom refueling.
69+
- `probe-and-drogue` — probe-and-drogue (basket) refueling.
70+
71+
The two properties apply to different kinds of aircraft, so set only the one relevant to yours:
72+
73+
- **Tanker aircraft:** set `fuel_provider` to the method the tanker dispenses. Leave `fuel_receiver`
74+
unset. This is what marks the aircraft as a tanker for VECTOR TO TANKER commands.
75+
- **Receiver aircraft:** set `fuel_receiver` to the method the aircraft takes fuel with, if any. Leave
76+
`fuel_provider` unset.
77+
78+
When a player asks for a vector to a tanker, SkyEye only sends them to a tanker whose `fuel_provider`
79+
matches their own aircraft's `fuel_receiver`.
80+
81+
## Overriding Built-In Aircraft
82+
83+
If an entry's `acmi_short_name` matches an aircraft already in the built-in encyclopedia, your entry
84+
replaces the built-in one. This lets you customize the data for an aircraft that already ships with SkyEye.
85+
86+
## Example
87+
88+
The example below adds the A-4 Skyhawk community mod. It is an attack aircraft that uses
89+
probe-and-drogue refueling, and its ACMI short name is `A-4E-C`.
90+
91+
92+
```yaml
93+
- acmi_short_name: A-4E-C
94+
tags:
95+
- fixed-wing
96+
- attack
97+
platform_designation: A-4
98+
type_designation: A-4E
99+
official_name: Skyhawk
100+
nickname: Scooter
101+
threat_radius_nm: 15
102+
fuel_receiver: probe-and-drogue
103+
```
104+
105+
With this file loaded, SkyEye recognizes the A-4, calls it a "Scooter", treats it as an attack
106+
aircraft with a 15 NM threat radius, and sends it to probe-and-drogue tankers.

internal/application/app.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/dharmab/skyeye/pkg/commands"
1818
"github.com/dharmab/skyeye/pkg/composer"
1919
"github.com/dharmab/skyeye/pkg/controller"
20+
"github.com/dharmab/skyeye/pkg/encyclopedia"
2021
"github.com/dharmab/skyeye/pkg/parser"
2122
"github.com/dharmab/skyeye/pkg/radar"
2223
"github.com/dharmab/skyeye/pkg/recognizer"
@@ -178,6 +179,9 @@ func NewApplication(config conf.Configuration) (*Application, error) {
178179
log.Info().Msg("constructing request parser")
179180
requestParser := parser.New(config.Callsign, locationNames, config.EnableTranscriptionLogging)
180181

182+
// Register custom aircraft into the encyclopedia before the radar, its first consumer, is built.
183+
encyclopedia.AddCustomAircraft(config.CustomAircraft)
184+
181185
log.Info().Msg("constructing radar scope")
182186

183187
// When threat monitoring requires SRS, the radar uses the SRS client to restrict the

internal/conf/configuration.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"time"
55

66
"github.com/dharmab/skyeye/pkg/coalitions"
7+
"github.com/dharmab/skyeye/pkg/encyclopedia"
78
"github.com/dharmab/skyeye/pkg/locations"
89
"github.com/dharmab/skyeye/pkg/simpleradio"
910
"github.com/dharmab/skyeye/pkg/synthesizer/voices"
@@ -102,6 +103,9 @@ type Configuration struct {
102103
ThreatMonitoringRequiresSRS bool
103104
// Locations is a slice of named locations that can be referenced in VECTOR calls.
104105
Locations []locations.Location
106+
// CustomAircraft is a slice of user-provided aircraft entries that extend or override the
107+
// built-in encyclopedia. Registered into the encyclopedia at application startup.
108+
CustomAircraft []encyclopedia.Aircraft
105109
// EnableTracing controls whether to publish traces
106110
EnableTracing bool
107111
// DiscordWebhookID is the ID of the Discord webhook

pkg/encyclopedia/custom.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package encyclopedia
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/dharmab/collections/sets"
9+
"github.com/martinlindhe/unit"
10+
"github.com/rs/zerolog/log"
11+
"gopkg.in/yaml.v3"
12+
)
13+
14+
// serializedAircraft is the on-disk representation of an Aircraft. It exists only to translate
15+
// between the human-friendly string schema and the Aircraft type's unexported enum fields; it is
16+
// never exposed outside this package.
17+
type serializedAircraft struct {
18+
ACMIShortName string `yaml:"acmi_short_name"`
19+
Tags []string `yaml:"tags"`
20+
PlatformDesignation string `yaml:"platform_designation"`
21+
TypeDesignation string `yaml:"type_designation"`
22+
NATOReportingName string `yaml:"nato_reporting_name"`
23+
OfficialName string `yaml:"official_name"`
24+
Nickname string `yaml:"nickname"`
25+
ThreatRadiusNM float64 `yaml:"threat_radius_nm"`
26+
FuelProvider string `yaml:"fuel_provider"`
27+
FuelReceiver string `yaml:"fuel_receiver"`
28+
}
29+
30+
// tagsByName maps the tag names accepted in custom aircraft files to their AircraftTag values.
31+
var tagsByName = map[string]AircraftTag{
32+
"fixed-wing": FixedWing,
33+
"rotary-wing": RotaryWing,
34+
"unarmed": Unarmed,
35+
"fighter": Fighter,
36+
"attack": Attack,
37+
}
38+
39+
// refuelingByName maps the refueling method names accepted in custom aircraft files to their
40+
// AirRefuelingMethod values. An empty string or "none" maps to NoAirRefueling.
41+
var refuelingByName = map[string]AirRefuelingMethod{
42+
"": NoAirRefueling,
43+
"none": NoAirRefueling,
44+
"boom": FlyingBoom,
45+
"probe-and-drogue": ProbeAndDrogue,
46+
}
47+
48+
// toAircraft validates the string schema and converts it into an Aircraft.
49+
func (s serializedAircraft) toAircraft() (Aircraft, error) {
50+
if strings.TrimSpace(s.ACMIShortName) == "" {
51+
return Aircraft{}, errors.New("aircraft must have a non-empty acmi_short_name")
52+
}
53+
54+
// SkyEye derives an aircraft's reporting name from these fields, so at least one is required or
55+
// the GCI would have nothing to call the contact.
56+
if strings.TrimSpace(s.NATOReportingName) == "" &&
57+
strings.TrimSpace(s.Nickname) == "" &&
58+
strings.TrimSpace(s.OfficialName) == "" &&
59+
strings.TrimSpace(s.PlatformDesignation) == "" {
60+
return Aircraft{}, fmt.Errorf("aircraft %q must have at least one of nato_reporting_name, nickname, official_name, or platform_designation", s.ACMIShortName)
61+
}
62+
63+
if len(s.Tags) == 0 {
64+
return Aircraft{}, fmt.Errorf("aircraft %q must have at least one tag", s.ACMIShortName)
65+
}
66+
tags := sets.New[AircraftTag]()
67+
wingCount := 0
68+
for _, name := range s.Tags {
69+
tag, ok := tagsByName[strings.ToLower(strings.TrimSpace(name))]
70+
if !ok {
71+
return Aircraft{}, fmt.Errorf("aircraft %q has unrecognized tag %q", s.ACMIShortName, name)
72+
}
73+
if tag == FixedWing || tag == RotaryWing {
74+
wingCount++
75+
}
76+
sets.Add(tags, tag)
77+
}
78+
if wingCount != 1 {
79+
return Aircraft{}, fmt.Errorf("aircraft %q must have exactly one of the tags fixed-wing or rotary-wing", s.ACMIShortName)
80+
}
81+
82+
if s.ThreatRadiusNM < 0 {
83+
return Aircraft{}, fmt.Errorf("aircraft %q has negative threat_radius_nm %v", s.ACMIShortName, s.ThreatRadiusNM)
84+
}
85+
86+
fuelProvider, ok := refuelingByName[strings.ToLower(strings.TrimSpace(s.FuelProvider))]
87+
if !ok {
88+
return Aircraft{}, fmt.Errorf("aircraft %q has unrecognized fuel_provider %q", s.ACMIShortName, s.FuelProvider)
89+
}
90+
fuelReceiver, ok := refuelingByName[strings.ToLower(strings.TrimSpace(s.FuelReceiver))]
91+
if !ok {
92+
return Aircraft{}, fmt.Errorf("aircraft %q has unrecognized fuel_receiver %q", s.ACMIShortName, s.FuelReceiver)
93+
}
94+
95+
return Aircraft{
96+
ACMIShortName: s.ACMIShortName,
97+
tags: tags,
98+
PlatformDesignation: s.PlatformDesignation,
99+
TypeDesignation: s.TypeDesignation,
100+
NATOReportingName: s.NATOReportingName,
101+
OfficialName: s.OfficialName,
102+
Nickname: s.Nickname,
103+
threatRadius: unit.Length(s.ThreatRadiusNM) * unit.NauticalMile,
104+
fuelProvider: fuelProvider,
105+
fuelReceiver: fuelReceiver,
106+
}, nil
107+
}
108+
109+
// LoadCustomAircraft parses custom aircraft data. YAML is a superset of JSON, so the data may be in
110+
// either format. Each entry is validated during conversion.
111+
func LoadCustomAircraft(data []byte) ([]Aircraft, error) {
112+
var serialized []serializedAircraft
113+
if err := yaml.Unmarshal(data, &serialized); err != nil {
114+
return nil, fmt.Errorf("failed to parse custom aircraft file: %w", err)
115+
}
116+
aircraft := make([]Aircraft, 0, len(serialized))
117+
for _, s := range serialized {
118+
a, err := s.toAircraft()
119+
if err != nil {
120+
return nil, err
121+
}
122+
aircraft = append(aircraft, a)
123+
}
124+
return aircraft, nil
125+
}
126+
127+
// AddCustomAircraft registers custom aircraft into the encyclopedia, keyed by ACMI short name.
128+
// An entry whose ACMI short name matches a built-in or previously added entry overrides it.
129+
func AddCustomAircraft(aircraft []Aircraft) {
130+
for _, data := range aircraft {
131+
event := log.Info().Str("aircraft", data.ACMIShortName)
132+
if _, exists := aircraftDataLUT[data.ACMIShortName]; exists {
133+
event = event.Bool("override", true)
134+
}
135+
aircraftDataLUT[data.ACMIShortName] = data
136+
event.Msg("loaded custom aircraft into encyclopedia")
137+
}
138+
}

0 commit comments

Comments
 (0)