diff --git a/AGENTS.md b/AGENTS.md index dda1e49..36c15bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ Operational rules for agents and humans changing this repository. This file is t | `internal/session` | Per-connection state + send worker | Does not import postoffice/server | | `internal/postoffice` | Registry (map/tree of participants) | Depends on session ports, not server | | `internal/metar` | Worker pool + injectable HTTP | Side-effect boundary | +| `internal/sweatbox` | Pure sim (apt/air parse, taxi, engine, kinematics) | **stdlib + `internal/geo` only**; no protocol/session/server | | `internal/server` | TCP accept, login, handlers, service HTTP | DI via `server.New` / `server.NewDefault` | | `internal/db` | Shared repositories + migrations | Used by FSD and web | | `internal/web` | Gin MPA + progressive enhancement + `/api/v1` | boring-web mandatory | @@ -30,11 +31,13 @@ Operational rules for agents and humans changing this repository. This file is t ``` cmd/openfsd → internal/server, internal/web, … -internal/server → session, postoffice, protocol, auth, metar, db +internal/server → session, postoffice, protocol, auth, metar, db, sweatbox internal/postoffice → geo, session internal/metar → protocol, session +internal/sweatbox → geo internal/web → auth, db, protocol - (and server only for service-HTTP DTOs until those move) + (and server only for service-HTTP DTOs until those move; + never sweatbox — control plane is service HTTP) pkg/fsdclient → protocol internal/auth → protocol internal/session → protocol @@ -52,9 +55,10 @@ Enforce with `scripts/check-import-graph.sh`. | `pkg/fsdclient` | `internal/*` | | `internal/session` | `postoffice`, `server`, `web`, `metar` | | `internal/geo` | Any non-stdlib import | -| `internal/web` | `session`, `postoffice`, `metar` | +| `internal/web` | `session`, `postoffice`, `metar`, `sweatbox` | | `internal/db` | `server`, `session`, `web`, `fsdclient` | | `internal/auth` | `server`, `session`, `web` | +| `internal/sweatbox` | `server`, `web`, `postoffice`, `session`, `db`, `auth`, `metar`, `fsdclient`, `protocol` | Stdlib heuristic: first path element contains no `.` (e.g. `fmt`, `net/http`). Third-party is never allowed in `pkg/protocol` or `internal/geo`. @@ -144,6 +148,7 @@ Enforced by `scripts/check-coverage.sh` (CI). | `internal/geo` | ≥98% | **Hard** | | `internal/auth` | ≥95% | **Hard** | | `internal/postoffice` | ≥90% | **Hard** | +| `internal/sweatbox` | ≥95% | **Hard** (target; may land in coverage script with later PR) | | `internal/web` | ≥80% | Soft (report only) | | Overall aspirational | 90% | Soft (report only) | | `cmd/*` | — | Excluded from measurement | diff --git a/internal/sweatbox/air.go b/internal/sweatbox/air.go new file mode 100644 index 0000000..1019444 --- /dev/null +++ b/internal/sweatbox/air.go @@ -0,0 +1,97 @@ +package sweatbox + +import ( + "fmt" + "strconv" + "strings" +) + +// ParseAIR parses TWRTrainer-compatible .air scenario text. +// Valid aircraft rows are returned even when other lines fail (best-effort load); +// errs lists per-line validation issues. Duplicate callsigns are rejected. +func ParseAIR(text string) ([]Aircraft, []string) { + var rows []Aircraft + var errs []string + seen := make(map[string]int) // callsign → first line + + lines := strings.Split(text, "\n") + for i, raw := range lines { + lineno := i + 1 + line := strings.TrimSpace(raw) + line = strings.TrimSuffix(line, "\r") + if line == "" || strings.HasPrefix(line, ";") { + continue + } + + f := strings.Split(line, ":") + if len(f) < 16 { + errs = append(errs, fmt.Sprintf("Invalid number of fields found on line %d", lineno)) + continue + } + + cs := strings.ToUpper(strings.TrimSpace(f[0])) + if cs == "" { + errs = append(errs, fmt.Sprintf("Missing callsign on line %d", lineno)) + continue + } + if first, dup := seen[cs]; dup { + errs = append(errs, fmt.Sprintf("Duplicate callsign (%s) on line %d (first on line %d)", cs, lineno, first)) + continue + } + + eng := strings.ToUpper(strings.TrimSpace(f[2])) + if eng != EnginePiston && eng != EngineTurboprop && eng != EngineJet && eng != EngineHelicopter { + errs = append(errs, fmt.Sprintf("Invalid engine type on line %d. Must be P, T, J or H.", lineno)) + continue + } + + rules := strings.ToUpper(strings.TrimSpace(f[3])) + if rules != RulesVFR && rules != RulesIFR && rules != RulesDVFR && rules != RulesSVFR { + errs = append(errs, fmt.Sprintf("Invalid flight plan type on line %d. Must be V, I, D or S.", lineno)) + continue + } + + mode := strings.ToUpper(strings.TrimSpace(f[10])) + if mode != XPDRModeNormal && mode != XPDRModeStandby { + errs = append(errs, fmt.Sprintf("Invalid transponder mode on line %d. Must be N or S. (Normal or Standby)", lineno)) + continue + } + + cruiseAlt, err := strconv.ParseFloat(strings.TrimSpace(f[6]), 64) + if err != nil { + errs = append(errs, fmt.Sprintf("Invalid numeric field on line %d", lineno)) + continue + } + lat, err1 := strconv.ParseFloat(strings.TrimSpace(f[11]), 64) + lon, err2 := strconv.ParseFloat(strings.TrimSpace(f[12]), 64) + alt, err3 := strconv.ParseFloat(strings.TrimSpace(f[13]), 64) + spd, err4 := strconv.ParseFloat(strings.TrimSpace(f[14]), 64) + hdg, err5 := strconv.ParseFloat(strings.TrimSpace(f[15]), 64) + if err1 != nil || err2 != nil || err3 != nil || err4 != nil || err5 != nil { + errs = append(errs, fmt.Sprintf("Invalid numeric field on line %d", lineno)) + continue + } + + rec := Aircraft{ + Callsign: cs, + Type: strings.ToUpper(strings.TrimSpace(f[1])), + Engine: eng, + Rules: rules, + Dep: strings.ToUpper(strings.TrimSpace(f[4])), + Arr: strings.ToUpper(strings.TrimSpace(f[5])), + CruiseAlt: int(cruiseAlt), + Route: f[7], + Remarks: f[8], + Squawk: strings.TrimSpace(f[9]), + XPDRMode: mode, + Lat: lat, + Lon: lon, + Alt: alt, + Speed: spd, + Heading: hdg, + } + seen[cs] = lineno + rows = append(rows, rec) + } + return rows, errs +} diff --git a/internal/sweatbox/air_test.go b/internal/sweatbox/air_test.go new file mode 100644 index 0000000..35ad61a --- /dev/null +++ b/internal/sweatbox/air_test.go @@ -0,0 +1,222 @@ +package sweatbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseAIR_KBTVFixture(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "KBTV_example.air")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + rows, errs := ParseAIR(string(data)) + if len(errs) != 0 { + t.Fatalf("unexpected errors: %v", errs) + } + if len(rows) != 3 { + t.Fatalf("rows = %d, want 3", len(rows)) + } + + a := rows[0] + if a.Callsign != "AAL123" { + t.Errorf("callsign = %q", a.Callsign) + } + if a.Type != "B738/F" { + t.Errorf("type = %q", a.Type) + } + if a.Engine != EngineJet || a.Rules != RulesIFR { + t.Errorf("engine/rules = %s/%s", a.Engine, a.Rules) + } + if a.Dep != "KBTV" || a.Arr != "KBOS" { + t.Errorf("dep/arr = %s/%s", a.Dep, a.Arr) + } + if a.CruiseAlt != 29000 { + t.Errorf("cruise = %d", a.CruiseAlt) + } + if a.Route != "BTV4 MPV LEB MHT" { + t.Errorf("route = %q", a.Route) + } + if a.Remarks != "/v/charts" { + t.Errorf("remarks = %q", a.Remarks) + } + if a.Squawk != "2200" || a.XPDRMode != XPDRModeStandby { + t.Errorf("sqk = %s mode = %s", a.Squawk, a.XPDRMode) + } + if a.Lat != 44.469758 || a.Lon != -73.154747 { + t.Errorf("pos = %v %v", a.Lat, a.Lon) + } + if a.Alt != 335 || a.Speed != 0 || a.Heading != 360 { + t.Errorf("alt/spd/hdg = %v/%v/%v", a.Alt, a.Speed, a.Heading) + } + + // Second: turboprop IFR + if rows[1].Callsign != "USA456" || rows[1].Engine != EngineTurboprop { + t.Errorf("row1 = %+v", rows[1]) + } + // Third: piston VFR + if rows[2].Callsign != "N4729H" || rows[2].Engine != EnginePiston || rows[2].Rules != RulesVFR { + t.Errorf("row2 = %+v", rows[2]) + } + if rows[2].Squawk != "1200" { + t.Errorf("VFR squawk = %s", rows[2].Squawk) + } +} + +func TestParseAIR_CommentsBlankCRLF(t *testing.T) { + text := "; header\r\n\r\nAAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90\r\n" + rows, errs := ParseAIR(text) + if len(errs) != 0 { + t.Fatalf("errs: %v", errs) + } + if len(rows) != 1 || rows[0].Callsign != "AAL1" { + t.Fatalf("rows: %+v", rows) + } + if rows[0].XPDRMode != XPDRModeNormal { + t.Errorf("mode = %s", rows[0].XPDRMode) + } +} + +func TestParseAIR_TooFewFields(t *testing.T) { + _, errs := ParseAIR("AAL1:B738:J:I:KBTV\n") + if len(errs) != 1 || !strings.Contains(errs[0], "Invalid number of fields") { + t.Fatalf("errs: %v", errs) + } +} + +func TestParseAIR_DuplicateCallsign(t *testing.T) { + line := "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90" + text := line + "\n" + line + "\n" + rows, errs := ParseAIR(text) + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1 (first kept)", len(rows)) + } + if len(errs) != 1 || !strings.Contains(errs[0], "Duplicate callsign") { + t.Fatalf("errs: %v", errs) + } +} + +func TestParseAIR_InvalidEngine(t *testing.T) { + line := "AAL1:B738:X:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90\n" + _, errs := ParseAIR(line) + if len(errs) != 1 || !strings.Contains(errs[0], "engine type") { + t.Fatalf("errs: %v", errs) + } +} + +func TestParseAIR_InvalidRules(t *testing.T) { + line := "AAL1:B738:J:Z:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90\n" + _, errs := ParseAIR(line) + if len(errs) != 1 || !strings.Contains(errs[0], "flight plan type") { + t.Fatalf("errs: %v", errs) + } +} + +func TestParseAIR_InvalidXPDR(t *testing.T) { + line := "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:X:44.0:-73.0:335:0:90\n" + _, errs := ParseAIR(line) + if len(errs) != 1 || !strings.Contains(errs[0], "transponder mode") { + t.Fatalf("errs: %v", errs) + } +} + +func TestParseAIR_InvalidNumeric(t *testing.T) { + tests := []struct { + name string + line string + }{ + {"cruise", "AAL1:B738:J:I:KBTV:KBOS:abc:DCT::2200:N:44.0:-73.0:335:0:90"}, + {"lat", "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:xx:-73.0:335:0:90"}, + {"lon", "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:yy:335:0:90"}, + {"alt", "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:zz:0:90"}, + {"spd", "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:no:90"}, + {"hdg", "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:no"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, errs := ParseAIR(tt.line + "\n") + if len(errs) != 1 || !strings.Contains(errs[0], "Invalid numeric field") { + t.Fatalf("errs: %v", errs) + } + }) + } +} + +func TestParseAIR_MissingCallsign(t *testing.T) { + line := ":B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90\n" + _, errs := ParseAIR(line) + if len(errs) != 1 || !strings.Contains(errs[0], "Missing callsign") { + t.Fatalf("errs: %v", errs) + } +} + +func TestParseAIR_AllEngineAndRules(t *testing.T) { + // P/T/J/H and V/I/D/S + mk := func(cs, eng, rules string) string { + return cs + ":C172:" + eng + ":" + rules + ":KBTV:KBOS:5000:DCT::1200:N:44.0:-73.0:335:0:90" + } + text := strings.Join([]string{ + mk("A1", "P", "V"), + mk("A2", "T", "I"), + mk("A3", "J", "D"), + mk("A4", "H", "S"), + mk("a5", "p", "v"), // case fold + }, "\n") + rows, errs := ParseAIR(text) + if len(errs) != 0 { + t.Fatalf("errs: %v", errs) + } + if len(rows) != 5 { + t.Fatalf("rows = %d", len(rows)) + } + if rows[4].Callsign != "A5" || rows[4].Engine != "P" || rows[4].Rules != "V" { + t.Errorf("case fold: %+v", rows[4]) + } +} + +func TestParseAIR_BestEffortPartialLoad(t *testing.T) { + text := strings.Join([]string{ + "GOOD1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90", + "BAD:too:few", + "GOOD2:C172:P:V:KBTV:KLEB:5000:DCT::1200:S:44.1:-73.1:335:0:180", + "GOOD1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90", // dup + }, "\n") + rows, errs := ParseAIR(text) + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2: %+v", len(rows), rows) + } + if len(errs) != 2 { + t.Fatalf("errs = %v, want 2", errs) + } + if rows[0].Callsign != "GOOD1" || rows[1].Callsign != "GOOD2" { + t.Errorf("callsigns: %s %s", rows[0].Callsign, rows[1].Callsign) + } +} + +func TestParseAIR_CruiseAltFloatTrunc(t *testing.T) { + // python: int(float(f[6])) + line := "AAL1:B738:J:I:KBTV:KBOS:29000.9:DCT::2200:N:44.0:-73.0:335:0:90\n" + rows, errs := ParseAIR(line) + if len(errs) != 0 { + t.Fatalf("errs: %v", errs) + } + if rows[0].CruiseAlt != 29000 { + t.Errorf("cruise = %d, want 29000", rows[0].CruiseAlt) + } +} + +func TestParseAIR_ExtraFieldsIgnored(t *testing.T) { + // More than 16 fields: we only use first 16 (split keeps extras in later indices; + // f[15] is still heading if no colons in heading — extra colons shift fields). + // With trailing extra colon-fields, heading is still f[15] if we have ≥16. + line := "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90:EXTRA:MORE\n" + rows, errs := ParseAIR(line) + if len(errs) != 0 { + t.Fatalf("errs: %v", errs) + } + if len(rows) != 1 || rows[0].Heading != 90 { + t.Fatalf("rows: %+v", rows) + } +} diff --git a/internal/sweatbox/apt.go b/internal/sweatbox/apt.go new file mode 100644 index 0000000..6e4be12 --- /dev/null +++ b/internal/sweatbox/apt.go @@ -0,0 +1,437 @@ +package sweatbox + +import ( + "fmt" + "strconv" + "strings" +) + +// Default airport header values (TWRTrainer fallbacks). +const ( + defaultPatternSize = 1.0 + defaultInitClimbProps = 3000.0 + defaultInitClimbJets = 5000.0 + defaultRegistration = "N" +) + +// ParseAPT parses TWRTrainer-compatible .apt text. +// The returned Airport is populated with whatever could be read; errs lists +// validation and format issues (non-fatal — callers decide hard-fail policy). +func ParseAPT(text string) (Airport, []string) { + apt := Airport{ + PatternSize: defaultPatternSize, + InitClimbProps: defaultInitClimbProps, + InitClimbJets: defaultInitClimbJets, + Registration: defaultRegistration, + } + var errs []string + var cur *Surface + + // Track surface keys for duplicate detection: "KIND:NAME". + seen := make(map[string]int) // key → first line number + + lines := strings.Split(text, "\n") + for i, raw := range lines { + lineno := i + 1 + line := strings.TrimSpace(raw) + // Strip CR from CRLF. + line = strings.TrimSuffix(line, "\r") + if line == "" || strings.HasPrefix(line, ";") { + continue + } + low := strings.ToLower(line) + + // Header keys. + if strings.HasPrefix(low, "icao=") { + apt.ICAO = strings.ToUpper(strings.TrimSpace(line[len("icao="):])) + if len(apt.ICAO) != 4 { + errs = append(errs, "ICAO code must be exactly 4 characters.") + } + continue + } + if strings.HasPrefix(low, "magnetic variation=") { + v, err := parseFloatField(line) + if err != nil { + errs = append(errs, fmt.Sprintf("Invalid magnetic variation on line %d", lineno)) + continue + } + apt.MagVar = v + continue + } + if strings.HasPrefix(low, "field elevation=") { + v, err := parseFloatField(line) + if err != nil { + errs = append(errs, fmt.Sprintf("Invalid field elevation on line %d", lineno)) + continue + } + apt.FieldElev = v + continue + } + if strings.HasPrefix(low, "pattern elevation=") { + v, err := parseFloatField(line) + if err != nil { + errs = append(errs, fmt.Sprintf("Invalid pattern elevation on line %d", lineno)) + continue + } + apt.PatternElev = v + continue + } + if strings.HasPrefix(low, "pattern size=") { + v, err := parseFloatField(line) + if err != nil { + errs = append(errs, fmt.Sprintf("Invalid pattern size on line %d", lineno)) + continue + } + apt.PatternSize = v + continue + } + if strings.HasPrefix(low, "initial climb props=") { + v, err := parseFloatField(line) + if err != nil { + errs = append(errs, fmt.Sprintf("Invalid initial climb props on line %d", lineno)) + continue + } + apt.InitClimbProps = v + continue + } + if strings.HasPrefix(low, "initial climb jets=") { + v, err := parseFloatField(line) + if err != nil { + errs = append(errs, fmt.Sprintf("Invalid initial climb jets on line %d", lineno)) + continue + } + apt.InitClimbJets = v + continue + } + if strings.HasPrefix(low, "jet airlines=") { + // Preserve original case of prefixes after '=' (sample is uppercase). + idx := strings.Index(line, "=") + apt.JetAirlines = strings.TrimSpace(line[idx+1:]) + continue + } + if strings.HasPrefix(low, "turboprop airlines=") { + idx := strings.Index(line, "=") + apt.TurboAirlines = strings.TrimSpace(line[idx+1:]) + continue + } + if strings.HasPrefix(low, "registration=") { + idx := strings.Index(line, "=") + apt.Registration = strings.TrimSpace(line[idx+1:]) + continue + } + + // Runway-only options (must appear after a RUNWAY section header). + if strings.HasPrefix(low, "turnoff=") && cur != nil && cur.Kind == SurfaceRunway { + val := strings.ToLower(strings.TrimSpace(line[len("turnoff="):])) + cur.TurnoffLeft = val == "left" + continue + } + if da, db, ok := parseDisplacedThreshold(line); ok { + if cur != nil && cur.Kind == SurfaceRunway { + cur.DispA = da + cur.DispB = db + } else { + errs = append(errs, fmt.Sprintf("Unknown line format found on line %d", lineno)) + } + continue + } + + // Section headers. + if name, ok := matchSection(line, "PARKING"); ok { + // PARKING names: \w+ + if !isWordName(name) { + errs = append(errs, fmt.Sprintf("Unknown line format found on line %d", lineno)) + cur = nil + continue + } + s := Surface{Kind: SurfaceParking, Name: strings.ToUpper(name)} + key := SurfaceParking + ":" + s.Name + if first, dup := seen[key]; dup { + errs = append(errs, fmt.Sprintf("Duplicate parking %s (first on line %d, again on line %d)", s.Name, first, lineno)) + } else { + seen[key] = lineno + } + apt.Surfaces = append(apt.Surfaces, s) + cur = &apt.Surfaces[len(apt.Surfaces)-1] + continue + } + if a, b, ok := matchRunway(line); ok { + s := Surface{ + Kind: SurfaceRunway, + Name: strings.ToUpper(a) + "/" + strings.ToUpper(b), + RwyA: strings.ToUpper(a), + RwyB: strings.ToUpper(b), + TurnoffLeft: true, // default + } + key := SurfaceRunway + ":" + s.Name + if first, dup := seen[key]; dup { + errs = append(errs, fmt.Sprintf("Duplicate runway %s (first on line %d, again on line %d)", s.Name, first, lineno)) + } else { + seen[key] = lineno + } + apt.Surfaces = append(apt.Surfaces, s) + cur = &apt.Surfaces[len(apt.Surfaces)-1] + continue + } + if name, ok := matchSection(line, "TAXIWAY"); ok { + if !isTaxiHoldName(name) { + errs = append(errs, fmt.Sprintf("Unknown line format found on line %d", lineno)) + cur = nil + continue + } + s := Surface{Kind: SurfaceTaxiway, Name: strings.ToUpper(name)} + key := SurfaceTaxiway + ":" + s.Name + if first, dup := seen[key]; dup { + errs = append(errs, fmt.Sprintf("Duplicate taxiway %s (first on line %d, again on line %d)", s.Name, first, lineno)) + } else { + seen[key] = lineno + } + apt.Surfaces = append(apt.Surfaces, s) + cur = &apt.Surfaces[len(apt.Surfaces)-1] + continue + } + if name, ok := matchSection(line, "HOLD"); ok { + if !isTaxiHoldName(name) { + errs = append(errs, fmt.Sprintf("Unknown line format found on line %d", lineno)) + cur = nil + continue + } + s := Surface{Kind: SurfaceHold, Name: strings.ToUpper(name)} + key := SurfaceHold + ":" + s.Name + if first, dup := seen[key]; dup { + errs = append(errs, fmt.Sprintf("Duplicate hold %s (first on line %d, again on line %d)", s.Name, first, lineno)) + } else { + seen[key] = lineno + } + apt.Surfaces = append(apt.Surfaces, s) + cur = &apt.Surfaces[len(apt.Surfaces)-1] + continue + } + + // Waypoint: "lat lon" with required decimal point (TWRTrainer regex). + if lat, lon, ok := parsePoint(line); ok { + if cur == nil { + errs = append(errs, fmt.Sprintf("Unknown line format found on line %d", lineno)) + continue + } + cur.Points = append(cur.Points, Point{Lat: lat, Lon: lon}) + continue + } + + errs = append(errs, fmt.Sprintf("Unknown line format found on line %d", lineno)) + } + + // Post-validation: waypoint counts. + for _, s := range apt.Surfaces { + switch s.Kind { + case SurfaceParking: + if len(s.Points) != 1 { + errs = append(errs, fmt.Sprintf("Parking area %s has no waypoint defined.", s.Name)) + } + case SurfaceRunway: + if len(s.Points) < 2 { + errs = append(errs, fmt.Sprintf("Runway %s does not have at least two waypoints defined.", s.Name)) + } + case SurfaceHold: + if len(s.Points) != 1 { + errs = append(errs, fmt.Sprintf("Hold %s has no waypoint defined.", s.Name)) + } + case SurfaceTaxiway: + if len(s.Points) < 2 { + errs = append(errs, fmt.Sprintf("Taxiway %s does not have at least two waypoints defined.", s.Name)) + } + } + } + + return apt, errs +} + +// parseFloatField extracts the value after a key= prefix. +func parseFloatField(line string) (float64, error) { + idx := strings.Index(line, "=") + if idx < 0 { + return 0, fmt.Errorf("missing =") + } + return strconv.ParseFloat(strings.TrimSpace(line[idx+1:]), 64) +} + +// parseDisplacedThreshold matches "displaced threshold=a/b". +func parseDisplacedThreshold(line string) (float64, float64, bool) { + low := strings.ToLower(line) + const prefix = "displaced threshold=" + if !strings.HasPrefix(low, prefix) { + return 0, 0, false + } + rest := strings.TrimSpace(line[len(prefix):]) + // Allow original-case prefix length: find '=' then rest. + if i := strings.Index(line, "="); i >= 0 { + rest = strings.TrimSpace(line[i+1:]) + } + parts := strings.Split(rest, "/") + if len(parts) != 2 { + return 0, 0, false + } + a, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64) + b, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return a, b, true +} + +// matchSection matches "[KIND name]" case-insensitively and returns the name. +func matchSection(line, kind string) (name string, ok bool) { + if len(line) < 3 || line[0] != '[' || line[len(line)-1] != ']' { + return "", false + } + inner := line[1 : len(line)-1] + parts := strings.Fields(inner) + if len(parts) != 2 { + return "", false + } + if !strings.EqualFold(parts[0], kind) { + return "", false + } + return parts[1], true +} + +// matchRunway matches [RUNWAY a/b] with TWRTrainer runway designators. +func matchRunway(line string) (a, b string, ok bool) { + if len(line) < 3 || line[0] != '[' || line[len(line)-1] != ']' { + return "", "", false + } + inner := line[1 : len(line)-1] + parts := strings.Fields(inner) + if len(parts) != 2 { + return "", "", false + } + if !strings.EqualFold(parts[0], "RUNWAY") { + return "", "", false + } + ends := strings.Split(parts[1], "/") + if len(ends) != 2 { + return "", "", false + } + if !isRunwayDesignator(ends[0]) || !isRunwayDesignator(ends[1]) { + return "", "", false + } + return ends[0], ends[1], true +} + +// isRunwayDesignator matches (?:[1-2]\d|3[0-6]|[1-9])[LRC]? +func isRunwayDesignator(s string) bool { + if s == "" { + return false + } + s = strings.ToUpper(s) + // Optional L/R/C suffix. + suf := byte(0) + last := s[len(s)-1] + if last == 'L' || last == 'R' || last == 'C' { + suf = last + s = s[:len(s)-1] + } + if s == "" { + return false + } + // Parse number 1–36, no leading zero (except we don't allow 0). + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > 36 { + return false + } + // Reject leading zeros: "01" etc. strconv accepts them but TWRTrainer does not. + if len(s) > 1 && s[0] == '0' { + return false + } + // Single digit 1-9 OK; 10-36 OK; with optional LRC. + _ = suf + return true +} + +// isWordName matches \w+ (ASCII letters, digits, underscore). +func isWordName(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' { + continue + } + return false + } + return true +} + +// isTaxiHoldName matches [A-Z]+\d* (letters then optional digits), case-insensitive. +func isTaxiHoldName(s string) bool { + if s == "" { + return false + } + i := 0 + for i < len(s) { + c := s[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') { + i++ + continue + } + break + } + if i == 0 { + return false + } + for ; i < len(s); i++ { + c := s[i] + if c < '0' || c > '9' { + return false + } + } + return true +} + +// parsePoint matches ^(\-?\d+\.\d+) (\-?\d+\.\d+)$ — decimal point required. +func parsePoint(line string) (lat, lon float64, ok bool) { + parts := strings.Fields(line) + if len(parts) != 2 { + return 0, 0, false + } + if !hasDecimalPoint(parts[0]) || !hasDecimalPoint(parts[1]) { + return 0, 0, false + } + // hasDecimalPoint guarantees a ParseFloat-able digit form. + lat, _ = strconv.ParseFloat(parts[0], 64) + lon, _ = strconv.ParseFloat(parts[1], 64) + return lat, lon, true +} + +func hasDecimalPoint(s string) bool { + // Must contain '.' and at least one digit on each side (optional leading '-'). + if s == "" { + return false + } + start := 0 + if s[0] == '-' { + start = 1 + } + dot := strings.IndexByte(s[start:], '.') + if dot < 0 { + return false + } + // digit(s) before and after the dot + before := s[start : start+dot] + after := s[start+dot+1:] + if before == "" || after == "" { + return false + } + for i := 0; i < len(before); i++ { + if before[i] < '0' || before[i] > '9' { + return false + } + } + for i := 0; i < len(after); i++ { + if after[i] < '0' || after[i] > '9' { + return false + } + } + return true +} diff --git a/internal/sweatbox/apt_test.go b/internal/sweatbox/apt_test.go new file mode 100644 index 0000000..8acaebd --- /dev/null +++ b/internal/sweatbox/apt_test.go @@ -0,0 +1,576 @@ +package sweatbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseAPT_KBTVFixture(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "KBTV_example.apt")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + apt, errs := ParseAPT(string(data)) + if len(errs) != 0 { + t.Fatalf("unexpected errors: %v", errs) + } + if apt.ICAO != "KBTV" { + t.Errorf("ICAO = %q, want KBTV", apt.ICAO) + } + if apt.MagVar != 16 { + t.Errorf("MagVar = %v, want 16", apt.MagVar) + } + if apt.FieldElev != 335 { + t.Errorf("FieldElev = %v, want 335", apt.FieldElev) + } + if apt.PatternElev != 1335 { + t.Errorf("PatternElev = %v, want 1335", apt.PatternElev) + } + if apt.PatternSize != 1 { + t.Errorf("PatternSize = %v, want 1", apt.PatternSize) + } + if apt.InitClimbProps != 10000 { + t.Errorf("InitClimbProps = %v, want 10000", apt.InitClimbProps) + } + if apt.InitClimbJets != 10000 { + t.Errorf("InitClimbJets = %v, want 10000", apt.InitClimbJets) + } + if apt.Registration != "N" { + t.Errorf("Registration = %q, want N", apt.Registration) + } + if !strings.Contains(apt.JetAirlines, "AAL") { + t.Errorf("JetAirlines missing AAL: %q", apt.JetAirlines) + } + if !strings.Contains(apt.TurboAirlines, "EGF") { + t.Errorf("TurboAirlines missing EGF: %q", apt.TurboAirlines) + } + + // Count surface kinds. + var nPark, nRwy, nTaxi, nHold int + for _, s := range apt.Surfaces { + switch s.Kind { + case SurfaceParking: + nPark++ + if len(s.Points) != 1 { + t.Errorf("parking %s: %d points", s.Name, len(s.Points)) + } + case SurfaceRunway: + nRwy++ + if len(s.Points) < 2 { + t.Errorf("runway %s: %d points", s.Name, len(s.Points)) + } + case SurfaceTaxiway: + nTaxi++ + case SurfaceHold: + nHold++ + } + } + if nPark != 16 { + t.Errorf("parking count = %d, want 16", nPark) + } + if nRwy != 2 { + t.Errorf("runway count = %d, want 2", nRwy) + } + if nTaxi != 12 { + t.Errorf("taxiway count = %d, want 12", nTaxi) + } + if nHold != 0 { + t.Errorf("hold count = %d, want 0", nHold) + } + + // Runway 19/1 options. + rwy := apt.FindSurface("19") + if rwy == nil { + t.Fatal("FindSurface(19) = nil") + } + if rwy.Name != "19/1" || rwy.RwyA != "19" || rwy.RwyB != "1" { + t.Errorf("runway 19/1 fields: %+v", rwy) + } + if rwy.DispA != 0 || rwy.DispB != 225 { + t.Errorf("displaced threshold = %v/%v, want 0/225", rwy.DispA, rwy.DispB) + } + if rwy.TurnoffLeft { + t.Error("turnoff=right expected TurnoffLeft=false") + } + + rwy2 := apt.FindSurface("33/15") + if rwy2 == nil { + t.Fatal("FindSurface(33/15) = nil") + } + if rwy2.DispA != 500 || rwy2.DispB != 0 { + t.Errorf("33/15 disp = %v/%v, want 500/0", rwy2.DispA, rwy2.DispB) + } + if !rwy2.TurnoffLeft { + t.Error("turnoff=left expected TurnoffLeft=true") + } + + // Parking lookup case-insensitive. + g1 := apt.FindSurface("g1") + if g1 == nil || g1.Kind != SurfaceParking { + t.Fatalf("FindSurface(g1) = %+v", g1) + } + if g1.Points[0].Lat < 44.46 || g1.Points[0].Lon > -73.15 { + t.Errorf("G1 point unexpected: %+v", g1.Points[0]) + } + + // Taxiway. + tw := apt.FindSurface("A") + if tw == nil || tw.Kind != SurfaceTaxiway || len(tw.Points) < 2 { + t.Fatalf("taxiway A: %+v", tw) + } +} + +func TestParseAPT_Defaults(t *testing.T) { + apt, errs := ParseAPT("icao=KXYZ\n") + if len(errs) != 0 { + t.Fatalf("errs: %v", errs) + } + if apt.PatternSize != defaultPatternSize { + t.Errorf("PatternSize default = %v", apt.PatternSize) + } + if apt.InitClimbProps != defaultInitClimbProps { + t.Errorf("InitClimbProps default = %v", apt.InitClimbProps) + } + if apt.InitClimbJets != defaultInitClimbJets { + t.Errorf("InitClimbJets default = %v", apt.InitClimbJets) + } + if apt.Registration != defaultRegistration { + t.Errorf("Registration default = %q", apt.Registration) + } +} + +func TestParseAPT_InvalidICAO(t *testing.T) { + _, errs := ParseAPT("icao=BTV\n") + if len(errs) == 0 { + t.Fatal("expected ICAO length error") + } + if !strings.Contains(errs[0], "ICAO") { + t.Errorf("err = %q", errs[0]) + } +} + +func TestParseAPT_CommentsAndBlankAndCRLF(t *testing.T) { + text := "; comment\r\n\r\nicao=KBTV\r\nmagnetic variation=16\r\n" + apt, errs := ParseAPT(text) + if len(errs) != 0 { + t.Fatalf("errs: %v", errs) + } + if apt.ICAO != "KBTV" || apt.MagVar != 16 { + t.Errorf("got %+v", apt) + } +} + +func TestParseAPT_InvalidNumericHeaders(t *testing.T) { + text := strings.Join([]string{ + "icao=KBTV", + "magnetic variation=abc", + "field elevation=xyz", + "pattern elevation=?", + "pattern size=nope", + "initial climb props=bad", + "initial climb jets=bad", + }, "\n") + _, errs := ParseAPT(text) + if len(errs) < 6 { + t.Fatalf("want ≥6 numeric errors, got %v", errs) + } +} + +func TestParseAPT_ParkingWaypointCounts(t *testing.T) { + text := ` +icao=KBTV +[PARKING G1] +; no waypoint +[PARKING G2] +44.1 -73.1 +44.2 -73.2 +` + _, errs := ParseAPT(text) + // G1: 0 points, G2: 2 points — both wrong + found := 0 + for _, e := range errs { + if strings.Contains(e, "Parking area") { + found++ + } + } + if found != 2 { + t.Fatalf("want 2 parking errors, got %v", errs) + } +} + +func TestParseAPT_RunwayValidation(t *testing.T) { + text := ` +icao=KBTV +[RUNWAY 19/1] +44.1 -73.1 +[RUNWAY 99/1] +[RUNWAY 22/04] +[RUNWAY 15L/33R] +44.0 -73.0 +44.1 -73.1 +` + apt, errs := ParseAPT(text) + // 19/1 has only 1 point → error + // 99/1 invalid designator → unknown line + // 22/04 leading zero on B → unknown + // 15L/33R valid with 2 points → ok + var hasRwyPts, hasUnknown bool + for _, e := range errs { + if strings.Contains(e, "Runway 19/1") { + hasRwyPts = true + } + if strings.Contains(e, "Unknown line") { + hasUnknown = true + } + } + if !hasRwyPts { + t.Errorf("missing runway waypoint error: %v", errs) + } + if !hasUnknown { + t.Errorf("missing unknown line for bad runway: %v", errs) + } + if apt.FindSurface("15L") == nil { + t.Error("expected 15L/33R to parse") + } +} + +func TestParseAPT_TaxiwayAndHold(t *testing.T) { + text := ` +icao=KBTV +[TAXIWAY A] +44.1 -73.1 +[TAXIWAY B1] +44.0 -73.0 +44.1 -73.1 +[HOLD HS1] +44.5 -73.5 +[HOLD HS2] +[TAXIWAY 1BAD] +[HOLD bad-name] +` + apt, errs := ParseAPT(text) + // A has 1 point → taxi error; HS2 no point → hold error; bad names → unknown + var taxiErr, holdErr, unknown int + for _, e := range errs { + switch { + case strings.Contains(e, "Taxiway A"): + taxiErr++ + case strings.Contains(e, "Hold HS2"): + holdErr++ + case strings.Contains(e, "Unknown line"): + unknown++ + } + } + if taxiErr != 1 { + t.Errorf("taxi err count %d: %v", taxiErr, errs) + } + if holdErr != 1 { + t.Errorf("hold err count %d: %v", holdErr, errs) + } + if unknown < 2 { + t.Errorf("want ≥2 unknown (bad names), got %d: %v", unknown, errs) + } + if apt.FindSurface("B1") == nil { + t.Error("B1 missing") + } + if apt.FindSurface("HS1") == nil { + t.Error("HS1 missing") + } +} + +func TestParseAPT_Duplicates(t *testing.T) { + text := ` +icao=KBTV +[PARKING G1] +44.1 -73.1 +[PARKING G1] +44.2 -73.2 +[TAXIWAY A] +44.0 -73.0 +44.1 -73.1 +[TAXIWAY A] +44.0 -73.0 +44.1 -73.1 +[RUNWAY 1/19] +44.0 -73.0 +44.1 -73.1 +[RUNWAY 1/19] +44.0 -73.0 +44.1 -73.1 +[HOLD H1] +44.5 -73.5 +[HOLD H1] +44.6 -73.6 +` + _, errs := ParseAPT(text) + var dups int + for _, e := range errs { + if strings.Contains(e, "Duplicate") { + dups++ + } + } + if dups != 4 { + t.Fatalf("want 4 duplicate errors, got %d: %v", dups, errs) + } +} + +func TestParseAPT_TurnoffAndDisplacedOutsideRunway(t *testing.T) { + text := ` +icao=KBTV +turnoff=left +displaced threshold=100/200 +[PARKING G1] +44.1 -73.1 +turnoff=right +` + _, errs := ParseAPT(text) + // turnoff without runway, displaced without runway, turnoff after parking + if len(errs) < 2 { + t.Fatalf("want errors for orphaned runway options: %v", errs) + } +} + +func TestParseAPT_UnknownAndOrphanPoint(t *testing.T) { + text := ` +icao=KBTV +not a valid line +44.1 -73.1 +` + _, errs := ParseAPT(text) + if len(errs) < 2 { + t.Fatalf("want ≥2 errors, got %v", errs) + } +} + +func TestParseAPT_DisplacedThresholdFormats(t *testing.T) { + text := ` +icao=KBTV +[RUNWAY 9/27] +displaced threshold=100/200 +44.0 -73.0 +44.1 -73.1 +` + apt, errs := ParseAPT(text) + if len(errs) != 0 { + t.Fatalf("errs: %v", errs) + } + r := apt.FindSurface("9") + if r == nil || r.DispA != 100 || r.DispB != 200 { + t.Fatalf("disp: %+v", r) + } +} + +func TestParseAPT_CaseInsensitiveHeaders(t *testing.T) { + text := ` +ICAO=kbtv +Magnetic Variation=14.5 +Field Elevation=100 +Pattern Elevation=1100 +Pattern Size=1.5 +Initial Climb Props=2000 +Initial Climb Jets=4000 +Jet Airlines=UAL,DAL +Turboprop Airlines=SKW +Registration=N +[parking ga1] +44.1 -73.1 +[runway 18/36] +turnoff=LEFT +44.0 -73.0 +44.1 -73.1 +[taxiway a] +44.0 -73.0 +44.1 -73.1 +[hold hs1] +44.2 -73.2 +` + apt, errs := ParseAPT(text) + if len(errs) != 0 { + t.Fatalf("errs: %v", errs) + } + if apt.ICAO != "KBTV" { + t.Errorf("ICAO=%q", apt.ICAO) + } + if apt.MagVar != 14.5 || apt.FieldElev != 100 || apt.PatternElev != 1100 { + t.Errorf("headers: %+v", apt) + } + if apt.PatternSize != 1.5 || apt.InitClimbProps != 2000 || apt.InitClimbJets != 4000 { + t.Errorf("climbs: %+v", apt) + } + if apt.JetAirlines != "UAL,DAL" || apt.TurboAirlines != "SKW" { + t.Errorf("airlines jet=%q turbo=%q", apt.JetAirlines, apt.TurboAirlines) + } + if apt.FindSurface("GA1") == nil || apt.FindSurface("18") == nil { + t.Error("surfaces missing") + } + r := apt.FindSurface("18") + if r == nil || !r.TurnoffLeft { + t.Errorf("turnoff left: %+v", r) + } +} + +func TestFindSurface_NilAndMiss(t *testing.T) { + var a *Airport + if a.FindSurface("X") != nil { + t.Error("nil airport should return nil") + } + apt := Airport{Surfaces: []Surface{{Kind: SurfaceParking, Name: "G1"}}} + if apt.FindSurface("NOPE") != nil { + t.Error("miss should be nil") + } +} + +func TestParseAPT_InvalidDisplacedThreshold(t *testing.T) { + // parseDisplacedThreshold returns false for bad format → unknown line if no match. + // With a runway current, malformed displaced that doesn't parse as displaced falls through. + text := ` +icao=KBTV +[RUNWAY 1/19] +displaced threshold=abc/def +44.0 -73.0 +44.1 -73.1 +` + _, errs := ParseAPT(text) + // "displaced threshold=abc/def" fails parseDisplacedThreshold → unknown line + found := false + for _, e := range errs { + if strings.Contains(e, "Unknown line") { + found = true + } + } + if !found { + t.Fatalf("want unknown line for bad displaced: %v", errs) + } +} + +func TestIsRunwayDesignator(t *testing.T) { + valid := []string{"1", "9", "10", "18", "27", "36", "15L", "33R", "9C", "36L"} + for _, s := range valid { + if !isRunwayDesignator(s) { + t.Errorf("%q should be valid", s) + } + } + invalid := []string{"", "0", "37", "01", "100", "L", "1X", "22/4"} + for _, s := range invalid { + if isRunwayDesignator(s) { + t.Errorf("%q should be invalid", s) + } + } +} + +func TestParsePoint(t *testing.T) { + lat, lon, ok := parsePoint("44.1 -73.2") + if !ok || lat != 44.1 || lon != -73.2 { + t.Fatalf("got %v %v %v", lat, lon, ok) + } + // Integers without decimal rejected. + if _, _, ok := parsePoint("44 -73"); ok { + t.Error("integers should fail") + } + if _, _, ok := parsePoint("44.1"); ok { + t.Error("single field should fail") + } + if _, _, ok := parsePoint("-.1 -73.0"); ok { + t.Error("missing digits before dot") + } + if _, _, ok := parsePoint("44. -73.0"); ok { + t.Error("missing digits after dot") + } + if _, _, ok := parsePoint("4a.1 -73.0"); ok { + t.Error("non-digit before dot") + } + if _, _, ok := parsePoint("44.1b -73.0"); ok { + t.Error("non-digit after dot") + } + if _, _, ok := parsePoint(""); ok { + t.Error("empty should fail") + } +} + +func TestParseAPT_InvalidParkingName(t *testing.T) { + // Name with punctuation fails isWordName after matchSection. + text := "icao=KBTV\n[PARKING G-1]\n44.1 -73.1\n" + _, errs := ParseAPT(text) + found := false + for _, e := range errs { + if strings.Contains(e, "Unknown line") { + found = true + } + } + if !found { + t.Fatalf("want unknown for bad parking name: %v", errs) + } +} + +func TestHelperEdgeCases(t *testing.T) { + // parseFloatField missing '=' + if _, err := parseFloatField("noequals"); err == nil { + t.Error("expected missing = error") + } + + // parseDisplacedThreshold: not a prefix; wrong slash count + if _, _, ok := parseDisplacedThreshold("nope"); ok { + t.Error("expected false for non-prefix") + } + if _, _, ok := parseDisplacedThreshold("displaced threshold=100"); ok { + t.Error("expected false for single value") + } + if _, _, ok := parseDisplacedThreshold("displaced threshold=1/2/3"); ok { + t.Error("expected false for three parts") + } + + // matchSection edge cases + if _, ok := matchSection("x", "PARKING"); ok { + t.Error("short line") + } + if _, ok := matchSection("[PARKING]", "PARKING"); ok { + t.Error("one field only") + } + if _, ok := matchSection("[OTHER X]", "PARKING"); ok { + t.Error("wrong kind") + } + if _, ok := matchSection("PARKING X]", "PARKING"); ok { + t.Error("missing open bracket") + } + + // matchRunway edge cases + if _, _, ok := matchRunway("x"); ok { + t.Error("short") + } + if _, _, ok := matchRunway("[RUNWAY]"); ok { + t.Error("one field") + } + if _, _, ok := matchRunway("[TAXIWAY A]"); ok { + t.Error("not runway kind") + } + if _, _, ok := matchRunway("[RUNWAY 19]"); ok { + t.Error("no slash") + } + + // isWordName / isTaxiHoldName + if isWordName("") { + t.Error("empty word") + } + if isWordName("G-1") { + t.Error("hyphen not word") + } + if isWordName("G_1") != true { + t.Error("underscore allowed") + } + if isTaxiHoldName("") { + t.Error("empty taxi") + } + if isTaxiHoldName("A1B") { + t.Error("letter after digit") + } + + // hasDecimalPoint empty + if hasDecimalPoint("") { + t.Error("empty decimal") + } + if hasDecimalPoint("-") { + t.Error("bare minus") + } +} diff --git a/internal/sweatbox/testdata/KBTV_example.air b/internal/sweatbox/testdata/KBTV_example.air new file mode 100644 index 0000000..89fef9f --- /dev/null +++ b/internal/sweatbox/testdata/KBTV_example.air @@ -0,0 +1,20 @@ +; 0 - callsign +; 1 - ac type +; 2 - engine type (P, T, J, H) +; 3 - fp type (V, I) +; 4 - dep apt +; 5 - arr apt +; 6 - crz alt +; 7 - route +; 8 - remarks +; 9 - squawk code +; 10 - normal or standby (N or S) +; 11 - lat +; 12 - lon +; 13 - alt +; 14 - ground speed +; 15 - hdg + +AAL123:B738/F:J:I:KBTV:KBOS:29000:BTV4 MPV LEB MHT:/v/charts:2200:S:44.469758:-73.154747:335:0:360 +USA456:B190/G:T:I:KBTV:KBOS:25000:DCT GPS:/v/vfr/charts:2201:S:44.468802:-73.154547:335:0:360 +N4729H:C172/A:P:V:KBTV:KLEB:7500:MPV LEB:/v/vfr:1200:S:44.463310:-73.152026:335:0:360 diff --git a/internal/sweatbox/testdata/KBTV_example.apt b/internal/sweatbox/testdata/KBTV_example.apt new file mode 100644 index 0000000..919b839 --- /dev/null +++ b/internal/sweatbox/testdata/KBTV_example.apt @@ -0,0 +1,158 @@ +icao=KBTV +magnetic variation=16 +field elevation=335 +pattern elevation=1335 +pattern size=1 +initial climb props=10000 +initial climb jets=10000 +jet airlines=AAL,ACA,AWE,BAW,BLR,BTA,CAL,CAX,COA,CVA,DAL,DLH,EAL,EIN,FDX,FFT,JAL,JBU,KAL,KLM,ML,NEA,NWA,QFA,SAC,SAS,SWA,UAL,UPS,USA,WWA +turboprop airlines=EGF,USA,COA,CJC,JZA +registration=N + +[PARKING G1] +44.46893 -73.15392 + +[PARKING G2] +44.46829 -73.15361 + +[PARKING G3] +44.4679 -73.15348 + +[PARKING G4] +44.46365 -73.15263 + +[PARKING GA1] +44.4637 -73.15221 + +[PARKING GA2] +44.4639 -73.15272 + +[PARKING GA3] +44.46538 -73.15325 + +[PARKING GA4] +44.46539 -73.1538 + +[PARKING GA5] +44.466 -73.15393 + +[PARKING GA6] +44.46638 -73.15446 + +[PARKING GA7] +44.46404 -73.14126 + +[PARKING GA8] +44.46419 -73.14151 + +[PARKING GA9] +44.46437 -73.1418 + +[PARKING CARGO1] +44.46456 -73.14812 + +[PARKING CARGO2] +44.46459 -73.14736 + +[PARKING CARGO3] +44.46467 -73.14666 + +[RUNWAY 19/1] +displaced threshold=0/225 +turnoff=right +44.4734 -73.15302 +44.47263 -73.15286 +44.47011 -73.15233 +44.4684 -73.15197 +44.46524 -73.1513 +44.46434 -73.15111 + +[RUNWAY 33/15] +displaced threshold=500/0 +turnoff=left +44.46571 -73.14166 +44.46598 -73.14211 +44.47044 -73.14932 +44.47263 -73.15287 +44.47329 -73.15394 +44.47614 -73.15855 +44.47953 -73.16403 +44.48049 -73.16559 + +[TAXIWAY A] +44.46512 -73.15227 +44.46584 -73.15242 +44.4669 -73.15264 +44.46765 -73.15279 +44.46826 -73.15292 +44.46903 -73.15308 +44.46991 -73.15326 +44.47079 -73.15344 +44.4733 -73.15396 +44.47339 -73.15301 + +[TAXIWAY B] +44.46826 -73.15292 +44.46843 -73.15198 +44.47044 -73.14933 + +[TAXIWAY C] +44.46512 -73.15227 +44.46525 -73.1513 +44.46548 -73.1482 +44.46559 -73.14682 +44.46585 -73.14342 +44.46597 -73.1421 + +[TAXIWAY D] +44.46572 -73.14168 +44.4666 -73.14155 +44.46688 -73.14165 +44.4705 -73.14531 +44.47074 -73.14564 +44.47166 -73.14718 +44.47206 -73.14785 +44.47264 -73.14881 +44.47315 -73.14964 +44.47345 -73.15014 +44.47372 -73.15059 + +[TAXIWAY E] +44.47372 -73.15059 +44.47341 -73.15302 + +[TAXIWAY F] +44.47371 -73.15058 +44.48098 -73.16257 +44.48108 -73.16299 +44.4811 -73.16485 +44.48049 -73.16558 + +[TAXIWAY G] +44.48049 -73.16558 +44.47919 -73.16704 +44.47823 -73.16551 +44.47483 -73.16 +44.47079 -73.15343 +44.4701 -73.15231 + +[TAXIWAY H] +44.47484 -73.16002 +44.47615 -73.15855 + +[TAXIWAY J] +44.4657 -73.14165 +44.46486 -73.14167 +44.46446 -73.14171 + +[TAXIWAY K] +44.46484 -73.14167 +44.46584 -73.14339 + +[TAXIWAY L] +44.46433 -73.15113 +44.46423 -73.15207 + +[TAXIWAY M] +44.47823 -73.16553 +44.47953 -73.16402 diff --git a/internal/sweatbox/types.go b/internal/sweatbox/types.go new file mode 100644 index 0000000..4cce37e --- /dev/null +++ b/internal/sweatbox/types.go @@ -0,0 +1,115 @@ +// Package sweatbox implements the pure sweatbox simulator domain: +// airport/scenario file parsing, taxi graph, aircraft state, and tick kinematics. +// +// Allowed imports: standard library and internal/geo only. +// Must not import session, postoffice, server, web, db, auth, metar, fsdclient, or protocol. +package sweatbox + +import "strings" + +// Surface kind identifiers (TWRTrainer section types). +const ( + SurfaceParking = "PARKING" + SurfaceRunway = "RUNWAY" + SurfaceTaxiway = "TAXIWAY" + SurfaceHold = "HOLD" +) + +// Engine type codes used in .air files. +const ( + EnginePiston = "P" + EngineTurboprop = "T" + EngineJet = "J" + EngineHelicopter = "H" +) + +// Flight-plan / rules codes used in .air files. +const ( + RulesVFR = "V" + RulesIFR = "I" + RulesDVFR = "D" + RulesSVFR = "S" +) + +// Transponder mode codes used in .air files. +const ( + XPDRModeNormal = "N" + XPDRModeStandby = "S" +) + +// Point is a geographic coordinate in decimal degrees. +type Point struct { + Lat float64 + Lon float64 +} + +// Surface is one airport geometry element: parking, runway, taxiway, or hold. +type Surface struct { + Kind string // PARKING, RUNWAY, TAXIWAY, HOLD + Name string // parking/taxi/hold name, or "A/B" for runways + Points []Point + + // Runway-only fields. + RwyA string // first end designator (e.g. "19") + RwyB string // reciprocal end designator (e.g. "1") + DispA float64 // displaced threshold feet for end A + DispB float64 // displaced threshold feet for end B + TurnoffLeft bool // true = left turnoff for end A (default true) +} + +// Airport is a parsed .apt file: header metadata plus ordered surfaces. +type Airport struct { + ICAO string + MagVar float64 // magnetic variation, degrees (east positive convention of source file) + FieldElev float64 // field elevation, feet MSL + PatternElev float64 // pattern altitude, feet MSL + PatternSize float64 // pattern size scale, NM (default 1) + InitClimbProps float64 // initial climb props, feet + InitClimbJets float64 // initial climb jets, feet + JetAirlines string // comma-separated callsign prefixes + TurboAirlines string // comma-separated callsign prefixes + Registration string // single-letter GA callsign prefix (e.g. "N") + Surfaces []Surface +} + +// FindSurface looks up a surface by name (case-insensitive). +// For runways, matches either end designator or the combined "A/B" name. +func (a *Airport) FindSurface(name string) *Surface { + if a == nil { + return nil + } + want := strings.ToUpper(name) + for i := range a.Surfaces { + s := &a.Surfaces[i] + if s.Kind == SurfaceRunway { + if s.RwyA == want || s.RwyB == want || s.Name == want { + return s + } + continue + } + if strings.ToUpper(s.Name) == want { + return s + } + } + return nil +} + +// Aircraft is one row from a .air scenario file (position/state snapshot). +type Aircraft struct { + Callsign string + Type string // ICAO type, may include equipment suffix (e.g. "B738/F") + Engine string // P, T, J, H + Rules string // V, I, D, S + Dep string + Arr string + CruiseAlt int + Route string + Remarks string + Squawk string + XPDRMode string // N or S + Lat float64 + Lon float64 + Alt float64 // feet + Speed float64 // knots + Heading float64 // degrees +} diff --git a/scripts/check-import-graph.sh b/scripts/check-import-graph.sh index ed12f1f..f2034e7 100755 --- a/scripts/check-import-graph.sh +++ b/scripts/check-import-graph.sh @@ -117,12 +117,13 @@ check_no_imports "internal/session" "${MODULE}/internal/session/..." \ # internal/geo — stdlib only check_stdlib_only "internal/geo" "${MODULE}/internal/geo/..." -# internal/web — must not import session, postoffice, metar +# internal/web — must not import session, postoffice, metar, sweatbox # (server is allowed only for service-HTTP DTOs; keep that coupling minimal) check_no_imports "internal/web" "${MODULE}/internal/web/..." \ "${MODULE}/internal/session" \ "${MODULE}/internal/postoffice" \ - "${MODULE}/internal/metar" + "${MODULE}/internal/metar" \ + "${MODULE}/internal/sweatbox" # internal/db — must not import server, session, web, fsdclient check_no_imports "internal/db" "${MODULE}/internal/db/..." \ @@ -137,6 +138,19 @@ check_no_imports "internal/auth" "${MODULE}/internal/auth/..." \ "${MODULE}/internal/session" \ "${MODULE}/internal/web" +# internal/sweatbox — pure sim; no orchestration / session / client packages +# (may import internal/geo + stdlib only; protocol encode lives in server host) +check_no_imports "internal/sweatbox" "${MODULE}/internal/sweatbox/..." \ + "${MODULE}/internal/server" \ + "${MODULE}/internal/web" \ + "${MODULE}/internal/postoffice" \ + "${MODULE}/internal/session" \ + "${MODULE}/internal/db" \ + "${MODULE}/internal/auth" \ + "${MODULE}/internal/metar" \ + "${MODULE}/pkg/fsdclient" \ + "${MODULE}/pkg/protocol" + if [[ "$failed" -ne 0 ]]; then echo echo "Import graph check FAILED. See AGENTS.md §2."