From 88317f22f29a781675a55206528384eb47f3a36d Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:46:04 -0400 Subject: [PATCH 01/26] docs: design spec for LV/RC conditional altitude commands Spec for mmp/vice#438: controller-issued "leaving {alt}, do X" and "reaching {alt}, do X" commands. Follows the existing A{fix}/... typed- dispatch precedent. Co-Authored-By: Claude Opus 4.7 --- ...-04-20-leaving-reaching-commands-design.md | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-20-leaving-reaching-commands-design.md diff --git a/docs/superpowers/specs/2026-04-20-leaving-reaching-commands-design.md b/docs/superpowers/specs/2026-04-20-leaving-reaching-commands-design.md new file mode 100644 index 000000000..29d0b66cc --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-leaving-reaching-commands-design.md @@ -0,0 +1,322 @@ +# Leaving/Reaching Altitude Conditional Commands + +**Issue:** [mmp/vice#438](https://github.com/mmp/vice/issues/438) +**Branch:** `leaving-reaching-commands` (branched from `upstream/master@65adaa0c`) +**Date:** 2026-04-20 + +## Summary + +Add controller-issued conditional commands that defer an action until the aircraft's altitude crosses a specified trigger: + +- `LV{alt}/{inner}` — "leaving {alt}, do {inner}". Example: `LV30/H010` → "leaving 3,000, fly heading 010". +- `RC{alt}/{inner}` — "reaching {alt}, do {inner}". Example: `RC100/DAAC` → "reaching 10,000, direct AAC". + +This is the controller-issued counterpart to issue #48 (which added the scenario-time, waypoint-gated version). The keyboard syntax follows the existing `A{fix}/{inner}` precedent. + +## Motivation + +Real-world ATC routinely uses phraseology like "leaving three thousand, turn left heading zero one zero" to chain a lateral maneuver to an altitude event. Vice has no way to express this today; controllers must watch the altitude themselves and issue the heading change manually. + +## Design decisions + +| Decision | Choice | +|---|---| +| Inner command set | Closed set of typed actions (H/L/R/LD/RD/D/S/M). Matches the `A{fix}/...` precedent. | +| `LV` trigger semantics | Fires once altitude is ≥50 ft past trigger in the direction of current vertical motion. Direction-agnostic — works whether climbing or descending through. | +| `RC` trigger semantics | Fires on first contact within 100 ft of target, regardless of vertical rate. | +| Invalid trigger at issue time | Reject with error. Trigger must be reachable (within a 500 ft band of current altitude, or lie between current altitude and assigned target). | +| Multiple pending slots | Single slot per aircraft. A new `LV`/`RC` replaces the prior one. | +| Readback | Full readback: "leaving three thousand, fly heading zero one zero". | +| Trigger firing | Silent execution. No radio transmission when the action fires. | +| STT voice support | Included v1. Voice grammar registers patterns for each trigger × supported inner combination. | +| Handoff / frequency change | Pending slot persists across handoffs. Matches `RR{alt}` behavior. | + +## Architecture + +### Data model + +One new field on `Nav`, a `ConditionalAction` interface with one concrete type per supported inner command, and a `ConditionalCommandIntent` for the readback. + +```go +// nav/nav.go +type ConditionalKind uint8 + +const ( + ConditionalLeaving ConditionalKind = iota + ConditionalReaching +) + +type ConditionalAction interface { + Execute(nav *Nav, simTime Time) + Render(rt *av.RadioTransmission, r *rand.Rand) +} + +type ConditionalHeading struct { + Heading int + Turn av.TurnMethod // TurnClosest/Left/Right + ByDegrees int // nonzero for LnnD / RnnD +} +type ConditionalDirectFix struct { + Fix string + Turn av.TurnMethod // TurnClosest / Left / Right +} +type ConditionalSpeed struct { Restriction av.SpeedRestriction } +type ConditionalMach struct { Mach float32 } + +type PendingConditionalCommand struct { + Kind ConditionalKind + Altitude float32 // feet MSL + Action ConditionalAction +} + +type Nav struct { + // ... existing fields ... + PendingConditionalCommand *PendingConditionalCommand +} +``` + +Slot is cleared when: +- Trigger fires (after the action executes). +- A new `LV`/`RC` command is issued (replaces). + +Slot is **not** cleared on new altitude assignment, heading change, speed change, approach clearance, handoff, or frequency change. + +### Supported inner commands + +| Token | Action type | Example | +|---|---|---| +| `H{hdg}` | `ConditionalHeading{Turn=TurnClosest}` | `LV30/H010` | +| `L{hdg}` / `R{hdg}` | `ConditionalHeading{Turn=Left/Right}` | `LV130/R100` | +| `L{deg}D` / `R{deg}D` | `ConditionalHeading{ByDegrees=N, Turn=Left/Right}` | `LV30/L20D` | +| `D{fix}` | `ConditionalDirectFix{Turn=TurnClosest}` | `RC100/DAAC` | +| `LD{fix}` / `RD{fix}` | `ConditionalDirectFix{Turn=Left/Right}` | `RC100/LDAAC` | +| `S{spd}` | `ConditionalSpeed{...}` | `RC50/S210` | +| `M{mach}` | `ConditionalMach{...}` | `RC350/M78` | + +Altitude-changing inner commands (`C`, `CVS`, `DVS`, `ED`, etc.) are explicitly rejected by the parser's default branch. No separate check. + +## Command parsing & dispatch + +In `sim/control.go`, `runOneControlCommand`: + +**Case `'L'`** — add a new branch before the existing `LD` / `LD` / `L` branches: + +```go +if strings.HasPrefix(command, "LV") && len(command) > 2 { + altStr, inner, ok := strings.Cut(command[2:], "/") + if !ok || inner == "" { return nil, ErrInvalidCommandSyntax } + alt, err := parseConditionalAltitude(altStr) + if err != nil { return nil, err } + action, err := parseConditionalAction(inner) + if err != nil { return nil, err } + return s.AssignConditional(tcw, callsign, ConditionalLeaving, alt, action) +} +``` + +**Case `'R'`** — analogous branch for `RC`, placed before the existing `RR` branch to avoid accidental fallthrough. + +**Altitude encoding** — reuse the `RR{alt}` convention: + +```go +func parseConditionalAltitude(s string) (float32, error) { + n, err := strconv.Atoi(s) + if err != nil { return 0, err } + if n > 600 && n%100 == 0 { n /= 100 } + return float32(n * 100), nil +} +``` + +**Inner parser** — `parseConditionalAction(inner string) (ConditionalAction, error)`: +Switch on `inner[0]` ∈ {H, L, R, D, S, M}; each branch validates its sub-grammar and returns the corresponding concrete `ConditionalAction`. Default branch returns `ErrInvalidCommandSyntax`, which naturally rejects altitude-changing inner commands. + +**Sim entry point:** + +```go +func (s *Sim) AssignConditional(tcw TCW, callsign av.ADSBCallsign, + kind ConditionalKind, altitude float32, action ConditionalAction) (av.CommandIntent, error) { + + s.mu.Lock(s.lg); defer s.mu.Unlock(s.lg) + + return s.dispatchControlledAircraftCommand(tcw, callsign, + func(tcw TCW, ac *Aircraft) av.CommandIntent { + if !triggerReachable(ac, kind, altitude) { + return nil // caller treats nil-intent as "unable" + } + ac.Nav.PendingConditionalCommand = &PendingConditionalCommand{ + Kind: kind, Altitude: altitude, Action: action, + } + return av.ConditionalCommandIntent{ + Kind: kind, Altitude: altitude, Action: action, + } + }) +} +``` + +**Reachability validation:** + +```go +func triggerReachable(ac *Aircraft, kind ConditionalKind, trigger float32) bool { + cur := ac.Altitude() + target := ac.Nav.Altitude.Assigned + switch kind { + case ConditionalLeaving: + if math.Abs(cur-trigger) <= 500 { return true } + if target == nil { return false } + return between(trigger, cur, *target) + case ConditionalReaching: + if target == nil { return math.Abs(cur-trigger) <= 500 } + return between(trigger, cur, *target) + } + return false +} +``` + +The 500 ft slack on `LV` handles "aircraft is at 3,050 climbing, controller says leaving 3,000" — shouldn't reject. + +## Trigger evaluation & firing + +In `sim.updateState` (adjacent to the existing `ReportReachingAltitude` check): + +```go +if pc := ac.Nav.PendingConditionalCommand; pc != nil && ac.IsAssociated() { + if conditionalTriggered(ac, pc) { + action := pc.Action + ac.Nav.PendingConditionalCommand = nil // clear BEFORE execute to prevent re-entry + action.Execute(&ac.Nav, s.State.SimTime) + } +} +``` + +**Trigger predicate:** + +```go +func conditionalTriggered(ac *Aircraft, pc *PendingConditionalCommand) bool { + alt := ac.Altitude() + diff := alt - pc.Altitude + switch pc.Kind { + case ConditionalLeaving: + // Fires once altitude is >50 ft past trigger in the direction of current vertical motion. + return math.Abs(diff) > 50 && + sameSign(diff, ac.Nav.FlightState.AltitudeRate) + case ConditionalReaching: + // First contact within 100 ft, regardless of vertical rate. + return math.Abs(diff) <= 100 + } + return false +} +``` + +The 50 ft `LV` threshold prevents firing on level-flight altitude noise. The 100 ft `RC` threshold matches the existing `RR{alt}` tolerance. + +**Action execution** — each concrete `ConditionalAction.Execute` calls the corresponding existing `Nav` method directly (`AssignHeading`, `DirectFix`, `AssignSpeed`, `AssignMach`). Because execution bypasses `runOneControlCommand`, no readback transmission is generated — silent execution is free. + +## Readback rendering + +```go +// aviation/intent.go +type ConditionalCommandIntent struct { + Kind ConditionalKind + Altitude float32 + Action ConditionalAction +} + +func (c ConditionalCommandIntent) Render(rt *RadioTransmission, r *rand.Rand) { + switch c.Kind { + case ConditionalLeaving: + rt.Add("[leaving|passing] {alt}, ", c.Altitude) + case ConditionalReaching: + rt.Add("[reaching|level at|on reaching] {alt}, ", c.Altitude) + } + c.Action.Render(rt, r) +} +``` + +Each `ConditionalAction.Render` emits only the action fragment (e.g., "fly heading 010", "direct AAC", "reduce speed to 210"), reusing the existing phraseology vocabulary. Concrete implementations draw on patterns from `AltitudeIntent`, `HeadingIntent`, `SpeedIntent`, `DirectFixIntent`. + +Example readbacks: + +| Command | Rendered | +|---|---| +| `LV30/H010` | "leaving three thousand, fly heading zero one zero" | +| `LV130/R100` | "passing one three thousand, right heading one zero zero" | +| `RC100/DAAC` | "reaching one zero thousand, direct alpha alpha charlie" | +| `RC50/S210` | "reaching five thousand, slowing to two one zero" | + +No `PendingTransmission*` type is added — trigger firing is silent by design. + +## STT grammar + +Voice support in `stt/handlers.go` — register one pattern per `(trigger × inner)` combination. The LV/RC trigger prefix is bolted onto each inner command's grammar fragment. + +**Trigger phrases** (alternation): + +- `LV`: "leaving {alt}" / "passing {alt}" +- `RC`: "reaching {alt}" / "level at {alt}" / "on reaching {alt}" + +**Inner phrases** — the established vocabulary for each supported inner command (from existing STT handlers): + +- `H{hdg}`: "fly heading {hdg}" +- `L{hdg}` / `R{hdg}`: "turn left|right heading {hdg}" +- `L{deg}D` / `R{deg}D`: "turn left|right {deg} degrees" +- `D{fix}`: "direct {fix}" / "proceed direct {fix}" +- `LD{fix}` / `RD{fix}`: "turn left|right direct {fix}" +- `S{spd}`: "maintain {spd}" / "reduce speed to {spd}" / "speed {spd}" +- `M{mach}`: "maintain mach {mach}" / "mach {mach}" + +**Implementation approach** — loop programmatically over the inner set, registering one `stt` command per pair: + +```go +for _, inner := range innerPatterns { + registerSTTCommand( + fmt.Sprintf("leaving|passing {altitude}, %s", inner.Grammar), + func(alt int, args ...any) string { + return fmt.Sprintf("LV%d/%s", alt, inner.ToCommand(args)) + }, + WithName("conditional_leaving_" + inner.Name), + WithPriority(11), + ) + // analogous for reaching +} +``` + +Exact framework fit (named rules vs. inline expansion) to be confirmed during implementation — the STT framework may need a small accommodation. The fallback of N inline registrations is acceptable. + +**Priority tuning:** set distinct priorities so "reaching {alt}" (for the new RC command) doesn't fuzzy-match with "report reaching {alt}" (the existing RR command). The existing `say<->stop` precedent from commit `3caf4fac` shows the pattern. + +## Testing + +### Unit tests (`nav` package) + +- Trigger predicate truth table for each `ConditionalKind` (climbing through, descending through, level at, level below, within-tolerance noise). +- Supersession behavior (new slot replaces prior). +- Persistence across synthetic handoff state transitions. +- `Execute` correctness per concrete action type — assert mutation matches direct nav-method call. + +### Sim-layer tests (`sim/control_test.go`) + +- Parse-and-install happy path, one row per `(trigger, inner)` combination. +- Parser rejections: altitude-changing inner, malformed altitude, empty inner, unknown inner prefix, missing slash. +- Unreachable-trigger rejections for each kind. +- Readback render round-trip for each action type. + +### End-to-end tests (`sim/e2e_test.go`) + +- `LV` scenario: aircraft climbing through trigger altitude; assert heading changes at the correct tick with no extra radio transmission at fire time. +- `RC` scenario: aircraft reaching target altitude; assert direct-fix installed, slot cleared, silent fire. + +### STT tests (`stt/handlers_test.go`) + +- One happy-path per registered voice pattern. +- Adversarial fuzzy-match guards — specifically verify "reaching {alt}" does not fire the `RR` command path and vice versa. + +### Regression hygiene + +- `go test ./sim/... ./nav/... ./stt/... ./aviation/...` must pass at every intermediate commit. +- No existing tests modified; this is pure addition. + +## Out of scope + +- Queuing multiple pending LV/RC actions per aircraft (single-slot only). +- Altitude-changing inner commands. +- Conditional commands triggered by events other than altitude (speed, time, position) — those exist as separate grammars (`A{fix}/...`, speed-until). +- Compound inner commands like speed-until (`S250/UFIX1/210`) nested inside LV/RC. From 6066ad25d7d85f55368b66f44979c9a8e4a14070 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:55:11 -0400 Subject: [PATCH 02/26] docs: implementation plan for LV/RC conditional altitude commands Co-Authored-By: Claude Opus 4.7 --- .../2026-04-20-leaving-reaching-commands.md | 1928 +++++++++++++++++ 1 file changed, 1928 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-20-leaving-reaching-commands.md diff --git a/docs/superpowers/plans/2026-04-20-leaving-reaching-commands.md b/docs/superpowers/plans/2026-04-20-leaving-reaching-commands.md new file mode 100644 index 000000000..9e69e3067 --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-leaving-reaching-commands.md @@ -0,0 +1,1928 @@ +# Leaving/Reaching Altitude Conditional Commands Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `LV{alt}/{inner}` (leaving) and `RC{alt}/{inner}` (reaching) controller commands that defer a lateral/speed/mach action until the aircraft crosses a trigger altitude. + +**Architecture:** Closed-set typed dispatch mirroring the existing `A{fix}/{inner}` pattern. A new `ConditionalAction` interface in `nav` with one concrete type per supported inner (heading, direct-fix, speed, mach). A single `PendingConditionalCommand` slot on `Nav`; `sim.updateState` fires it silently when the trigger condition is met. + +**Tech Stack:** Go 1.x, existing vice sim/nav/aviation/stt packages. `go test ./...` for verification. + +**Spec:** `docs/superpowers/specs/2026-04-20-leaving-reaching-commands-design.md` + +--- + +## File map + +**New files:** +- `nav/conditional.go` — `ConditionalKind`, `ConditionalAction` interface, concrete action types, `conditionalTriggered` predicate +- `nav/conditional_test.go` — unit tests for actions and trigger predicate + +**Modified files:** +- `nav/nav.go` — add `PendingConditionalCommand *PendingConditionalCommand` field to `Nav` +- `aviation/intent.go` — add `ConditionalCommandIntent` near the other special intents +- `sim/control.go` — add `parseConditionalAltitude`, `parseConditionalAction`, `triggerReachable`, `AssignConditional`; add dispatch branches in cases `'L'` and `'R'` +- `sim/control_test.go` — integration tests for dispatch and rejection paths +- `sim/sim.go` — add trigger check in `updateState` +- `sim/e2e_test.go` — end-to-end tick-through scenarios +- `stt/handlers.go` — register voice patterns for both triggers and each inner command +- `stt/handlers_test.go` — STT happy-path and adversarial tests +- `whatsnew.md` — user-visible changelog entry + +**Commit cadence:** one commit per task. Every commit must leave `go test ./sim/... ./nav/... ./stt/... ./aviation/...` green. + +--- + +### Task 1: `ConditionalKind`, `ConditionalAction` interface, `PendingConditionalCommand` struct, Nav field + +**Files:** +- Create: `nav/conditional.go` +- Create: `nav/conditional_test.go` +- Modify: `nav/nav.go` (add one field inside `Nav`) + +- [ ] **Step 1: Write the failing test** + +Create `nav/conditional_test.go`: + +```go +package nav + +import ( + "testing" +) + +func TestNavHasPendingConditionalCommandField(t *testing.T) { + var n Nav + if n.PendingConditionalCommand != nil { + t.Fatalf("PendingConditionalCommand should default to nil, got %+v", n.PendingConditionalCommand) + } + n.PendingConditionalCommand = &PendingConditionalCommand{ + Kind: ConditionalLeaving, + Altitude: 3000, + } + if n.PendingConditionalCommand.Kind != ConditionalLeaving { + t.Fatalf("expected ConditionalLeaving, got %d", n.PendingConditionalCommand.Kind) + } + if n.PendingConditionalCommand.Altitude != 3000 { + t.Fatalf("expected 3000, got %v", n.PendingConditionalCommand.Altitude) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./nav/... -run TestNavHasPendingConditionalCommandField -v` +Expected: FAIL — undefined `PendingConditionalCommand`, `ConditionalLeaving`. + +- [ ] **Step 3: Create `nav/conditional.go`** + +```go +// nav/conditional.go +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package nav + +import ( + "math/rand/v2" + + av "github.com/mmp/vice/aviation" +) + +// ConditionalKind identifies which altitude-event triggers the deferred action. +type ConditionalKind uint8 + +const ( + // ConditionalLeaving fires once the aircraft's altitude has passed the + // trigger by more than a small tolerance in the direction of current + // vertical motion. + ConditionalLeaving ConditionalKind = iota + + // ConditionalReaching fires on first contact within 100 ft of the trigger + // altitude, regardless of vertical rate. + ConditionalReaching +) + +// ConditionalAction is the deferred action to execute when a LV/RC trigger +// fires. Concrete types cover the closed set of supported inner commands +// (heading, direct-fix, speed, mach). +type ConditionalAction interface { + // Execute mutates nav to carry out the deferred action. Called with the + // PendingConditionalCommand slot already cleared, so re-entry is safe. + Execute(nav *Nav, simTime Time) + + // Render emits the action-specific readback fragment (e.g., "fly heading + // 010") used inside ConditionalCommandIntent. + Render(rt *av.RadioTransmission, r *rand.Rand) +} + +// PendingConditionalCommand is the single slot on Nav that stores a +// deferred LV/RC action. A new LV/RC command supersedes any prior slot; +// successful trigger firing clears it. +type PendingConditionalCommand struct { + Kind ConditionalKind + Altitude float32 // feet MSL + Action ConditionalAction +} +``` + +- [ ] **Step 4: Add the field to `Nav` in `nav/nav.go`** + +In `nav/nav.go`, add the following field to the `Nav` struct near the existing `ReportReachingAltitude` field: + +```go + // PendingConditionalCommand stores a single deferred LV/RC action + // (e.g., "leaving 3,000, fly heading 010"). Cleared when the trigger + // fires or when a new LV/RC command is installed. Not cleared on + // new altitude/heading/speed assignments or on handoff. + PendingConditionalCommand *PendingConditionalCommand +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `go test ./nav/... -run TestNavHasPendingConditionalCommandField -v` +Expected: PASS. + +Also run the full nav suite to verify no regression: `go test ./nav/...` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add nav/conditional.go nav/conditional_test.go nav/nav.go +git commit -m "nav: add ConditionalAction interface and PendingConditionalCommand slot" +``` + +--- + +### Task 2: `ConditionalHeading` with Execute and Render + +**Files:** +- Modify: `nav/conditional.go` +- Modify: `nav/conditional_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `nav/conditional_test.go`: + +```go +import ( + // ...existing imports... + av "github.com/mmp/vice/aviation" + "github.com/mmp/vice/math" + "math/rand/v2" +) + +func TestConditionalHeadingExecuteClosest(t *testing.T) { + // Aircraft flying a heading; conditional heading assigns 010. + n := makeTestNav(t, 180) // helper: Nav with current heading 180, altitude 2000, etc. + action := ConditionalHeading{Heading: 10, Turn: av.TurnClosest} + action.Execute(&n, Time{}) + if assigned, ok := n.AssignedHeading(); !ok || assigned != 10 { + t.Fatalf("expected assigned heading 10, got ok=%v heading=%v", ok, assigned) + } +} + +func TestConditionalHeadingExecuteByDegreesLeft(t *testing.T) { + n := makeTestNav(t, 180) + action := ConditionalHeading{ByDegrees: 30, Turn: av.TurnLeft} + action.Execute(&n, Time{}) + // TurnLeft 30 from 180 -> 150 + if assigned, ok := n.AssignedHeading(); !ok || assigned != 150 { + t.Fatalf("expected assigned heading 150, got ok=%v heading=%v", ok, assigned) + } +} + +func TestConditionalHeadingRender(t *testing.T) { + cases := []struct { + action ConditionalHeading + want string // substring in written form + }{ + {ConditionalHeading{Heading: 10, Turn: av.TurnClosest}, "010"}, + {ConditionalHeading{Heading: 100, Turn: av.TurnRight}, "right"}, + {ConditionalHeading{Heading: 100, Turn: av.TurnLeft}, "left"}, + {ConditionalHeading{ByDegrees: 20, Turn: av.TurnLeft}, "left 20"}, + } + r := rand.New(rand.NewPCG(1, 2)) + for _, tc := range cases { + rt := &av.RadioTransmission{} + tc.action.Render(rt, r) + written := rt.Written(r) + if !strings.Contains(strings.ToLower(written), strings.ToLower(tc.want)) { + t.Errorf("Render(%+v) = %q; want containing %q", tc.action, written, tc.want) + } + } +} +``` + +Add a test helper at the bottom of `nav/conditional_test.go` (or reuse an existing builder if present — search `nav/*_test.go` for `makeTestNav` or `newTestNav`): + +```go +func makeTestNav(t *testing.T, heading math.MagneticHeading) Nav { + t.Helper() + n := Nav{ + Rand: rand.New(rand.NewPCG(1, 2)), + } + n.FlightState.Heading = heading + n.FlightState.Altitude = 2000 + return n +} +``` + +If the test builder doesn't compile because `FlightState` lives elsewhere or needs other fields for `AssignHeading` to work, read `nav/commands_test.go` for the existing test-nav-builder pattern and reuse it instead of inventing a new one. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./nav/... -run TestConditionalHeading -v` +Expected: FAIL — `ConditionalHeading` undefined. + +- [ ] **Step 3: Implement `ConditionalHeading`** + +Append to `nav/conditional.go`: + +```go +// ConditionalHeading is a deferred heading assignment. Exactly one of +// Heading or ByDegrees is nonzero: +// - Heading != 0 → fly (or turn to) the absolute heading. +// - ByDegrees != 0 → turn N degrees from present heading in the given +// direction (Turn must be TurnLeft or TurnRight). +type ConditionalHeading struct { + Heading int // 1..360, 0 if unused + Turn av.TurnDirection // TurnClosest, TurnLeft, TurnRight + ByDegrees int // nonzero for LnnD / RnnD +} + +func (c ConditionalHeading) Execute(nav *Nav, simTime Time) { + if c.ByDegrees != 0 { + switch c.Turn { + case av.TurnLeft: + nav.assignHeading( + nav.FlightState.Heading.Turn(float32(-c.ByDegrees)), + av.TurnLeft, simTime, 0) + case av.TurnRight: + nav.assignHeading( + nav.FlightState.Heading.Turn(float32(c.ByDegrees)), + av.TurnRight, simTime, 0) + } + return + } + nav.assignHeading(math.MagneticHeading(c.Heading), c.Turn, simTime, 0) +} + +func (c ConditionalHeading) Render(rt *av.RadioTransmission, r *rand.Rand) { + if c.ByDegrees != 0 { + switch c.Turn { + case av.TurnLeft: + rt.Add("[left|turn left] {num} degrees", c.ByDegrees) + case av.TurnRight: + rt.Add("[right|turn right] {num} degrees", c.ByDegrees) + } + return + } + switch c.Turn { + case av.TurnLeft: + rt.Add("[left heading|turn left heading] {hdg}", c.Heading) + case av.TurnRight: + rt.Add("[right heading|turn right heading] {hdg}", c.Heading) + default: + rt.Add("[fly heading|heading] {hdg}", c.Heading) + } +} +``` + +Note: `nav.assignHeading` (lowercase, the internal helper at `nav/commands.go:473`) is used directly to avoid the validation in the public `AssignHeading` that we don't need here (validation already happened at command-issue time). `nav.FlightState.Heading.Turn(deg float32)` is the existing heading-math helper. + +Verify the helper exists: `grep -n "func (h MagneticHeading) Turn" math/math.go`. If the signature differs, adjust the call. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./nav/... -run TestConditionalHeading -v` +Expected: PASS. + +Run: `go test ./nav/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add nav/conditional.go nav/conditional_test.go +git commit -m "nav: add ConditionalHeading action with Execute and Render" +``` + +--- + +### Task 3: `ConditionalDirectFix` with Execute and Render + +**Files:** +- Modify: `nav/conditional.go` +- Modify: `nav/conditional_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `nav/conditional_test.go`: + +```go +func TestConditionalDirectFixExecute(t *testing.T) { + n := makeTestNavWithRoute(t, "AAC") // helper: Nav whose Waypoints contains fix "AAC" + action := ConditionalDirectFix{Fix: "AAC", Turn: av.TurnClosest} + action.Execute(&n, Time{}) + // After direct-fix, the first waypoint should be the target fix. + if len(n.Waypoints) == 0 || n.Waypoints[0].Fix != "AAC" { + t.Fatalf("expected first waypoint AAC, got %+v", n.Waypoints) + } +} + +func TestConditionalDirectFixRender(t *testing.T) { + cases := []struct { + action ConditionalDirectFix + want string + }{ + {ConditionalDirectFix{Fix: "AAC", Turn: av.TurnClosest}, "direct"}, + {ConditionalDirectFix{Fix: "AAC", Turn: av.TurnLeft}, "left"}, + {ConditionalDirectFix{Fix: "AAC", Turn: av.TurnRight}, "right"}, + } + r := rand.New(rand.NewPCG(1, 2)) + for _, tc := range cases { + rt := &av.RadioTransmission{} + tc.action.Render(rt, r) + written := strings.ToLower(rt.Written(r)) + if !strings.Contains(written, strings.ToLower(tc.want)) { + t.Errorf("Render(%+v) = %q; want containing %q", tc.action, written, tc.want) + } + } +} +``` + +Add helper `makeTestNavWithRoute` — model it on `makeTestNav` plus whatever `nav/commands_test.go` does to set up a Nav with a named waypoint. If an equivalent helper already exists (e.g., `newNavWithFix`), reuse that. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./nav/... -run TestConditionalDirectFix -v` +Expected: FAIL — undefined `ConditionalDirectFix`. + +- [ ] **Step 3: Implement `ConditionalDirectFix`** + +Append to `nav/conditional.go`: + +```go +// ConditionalDirectFix is a deferred direct-to-fix instruction. +type ConditionalDirectFix struct { + Fix string + Turn av.TurnDirection // TurnClosest, TurnLeft, TurnRight +} + +func (c ConditionalDirectFix) Execute(nav *Nav, simTime Time) { + // Call the internal direct-fix path. The public DirectFix returns an + // intent we don't need since execution is silent. + _ = nav.directFix(c.Fix, c.Turn, simTime, 0) +} + +func (c ConditionalDirectFix) Render(rt *av.RadioTransmission, r *rand.Rand) { + switch c.Turn { + case av.TurnLeft: + rt.Add("[left direct|turn left direct] {fix}", c.Fix) + case av.TurnRight: + rt.Add("[right direct|turn right direct] {fix}", c.Fix) + default: + rt.Add("[direct|proceed direct] {fix}", c.Fix) + } +} +``` + +If `directFix` (lowercase) doesn't exist as an internal helper, split the public `DirectFix` to carve one out. Verify: `grep -n "func (nav \*Nav) directFix\|func (nav \*Nav) DirectFix" nav/*.go`. The public signature is `DirectFix(fix string, turn av.TurnDirection, simTime Time, delayReduction time.Duration) av.CommandIntent` per `nav/commands.go:647`. If no lowercase internal helper exists, calling the public `DirectFix` and discarding the intent is fine — include a comment explaining that the return value is intentionally discarded because the silent-fire path doesn't read back. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./nav/... -run TestConditionalDirectFix -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add nav/conditional.go nav/conditional_test.go +git commit -m "nav: add ConditionalDirectFix action" +``` + +--- + +### Task 4: `ConditionalSpeed` and `ConditionalMach` + +**Files:** +- Modify: `nav/conditional.go` +- Modify: `nav/conditional_test.go` + +- [ ] **Step 1: Write the failing tests** + +Append to `nav/conditional_test.go`: + +```go +func TestConditionalSpeedExecute(t *testing.T) { + n := makeTestNav(t, 180) + sr := av.MakeExactSpeedRestriction(210) + action := ConditionalSpeed{Restriction: sr} + action.Execute(&n, Time{}) + if n.Speed.Assigned == nil { + t.Fatalf("expected Speed.Assigned set, got nil") + } + if got, _ := n.Speed.Assigned.ExactValue(); got != 210 { + t.Fatalf("expected 210, got %v", got) + } +} + +func TestConditionalMachExecute(t *testing.T) { + n := makeTestNav(t, 180) + n.FlightState.Altitude = 30000 + action := ConditionalMach{Mach: 0.78} + // ConditionalMach.Execute needs a temperature lookup. The production + // path gets temp from the sim's weather model; for the test we can + // accept that Execute takes temp from nav.FlightState.Temperature (or + // whatever the field is). If no such field exists, Execute must be + // passed temp some other way — adjust the action shape accordingly. + action.Execute(&n, Time{}) + // Assert the nav state was updated — exact assertion depends on how + // AssignMach is observable on Nav (probably Speed.Assigned with IsMach + // set). Inspect nav/commands.go:129 for the surface and assert on it. +} +``` + +Look at `nav/commands.go:129` for `AssignMach(mach float32, afterAltitude bool, temp av.Temperature) av.CommandIntent` to understand what temperature is expected and which Nav state it mutates. The test assertion should match that surface. + +Validate `av.MakeExactSpeedRestriction` exists: `grep -n "func MakeExactSpeedRestriction" aviation/*.go`. If the constructor is named differently (e.g., `NewSpeedRestriction`, `ParseSpeedRestriction`), adjust. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./nav/... -run "TestConditional(Speed|Mach)" -v` +Expected: FAIL — undefined types. + +- [ ] **Step 3: Implement `ConditionalSpeed` and `ConditionalMach`** + +Append to `nav/conditional.go`: + +```go +// ConditionalSpeed is a deferred speed assignment. +type ConditionalSpeed struct { + Restriction av.SpeedRestriction +} + +func (c ConditionalSpeed) Execute(nav *Nav, simTime Time) { + sr := c.Restriction + _ = nav.AssignSpeed(&sr, false) +} + +func (c ConditionalSpeed) Render(rt *av.RadioTransmission, r *rand.Rand) { + spd, _ := c.Restriction.ExactValue() + rt.Add("[reduce speed to|maintain|slowing to] {spd}", spd) +} + +// ConditionalMach is a deferred mach-speed assignment. +type ConditionalMach struct { + Mach float32 +} + +func (c ConditionalMach) Execute(nav *Nav, simTime Time) { + // Mach execution requires a temperature. Use the nav's recorded temperature + // at the flight level; this is an approximation, since the production + // AssignMach path queries a live weather model, but it's acceptable here + // because the deferred action fires when we've just reached the target + // altitude, which is close to the altitude the controller was considering + // when issuing the command. + _ = nav.AssignMach(c.Mach, false, nav.FlightState.Temperature) +} + +func (c ConditionalMach) Render(rt *av.RadioTransmission, r *rand.Rand) { + rt.Add("[mach|maintain mach] {mach}", c.Mach) +} +``` + +If `nav.FlightState.Temperature` doesn't exist, check `nav/nav.go` around the `FlightState` definition for how temperature is exposed. If it's not a field on FlightState, either: +- Add a `Temperature` parameter to `ConditionalAction.Execute(...)` (requires threading through `sim.updateState`), or +- Look the temperature up via the sim's weather model when firing, before calling `Execute`. + +The second option is cleaner if `Execute` grows a temperature parameter: change the interface to `Execute(nav *Nav, simTime Time, temp av.Temperature)` and pass a zero temperature for non-mach actions. Verify `av.Temperature` type: `grep -n "type Temperature" aviation/*.go`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./nav/... -run "TestConditional(Speed|Mach)" -v` +Expected: PASS. + +Run: `go test ./nav/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add nav/conditional.go nav/conditional_test.go +git commit -m "nav: add ConditionalSpeed and ConditionalMach actions" +``` + +--- + +### Task 5: Trigger predicate `conditionalTriggered` + +**Files:** +- Modify: `nav/conditional.go` +- Modify: `nav/conditional_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `nav/conditional_test.go`: + +```go +func TestConditionalTriggered(t *testing.T) { + cases := []struct { + name string + kind ConditionalKind + trigger float32 + altitude float32 + rate float32 // vertical rate (positive = climb) + want bool + }{ + // --- ConditionalLeaving --- + {"LV climbing well past", ConditionalLeaving, 3000, 3200, +500, true}, + {"LV descending well past", ConditionalLeaving, 3000, 2800, -500, true}, + {"LV level at trigger", ConditionalLeaving, 3000, 3000, 0, false}, + {"LV within tolerance climbing", ConditionalLeaving, 3000, 3020, +500, false}, // <50ft past + {"LV 60ft past climbing", ConditionalLeaving, 3000, 3060, +500, true}, + {"LV 60ft below climbing (wrong dir)", ConditionalLeaving, 3000, 2940, +500, false}, + // --- ConditionalReaching --- + {"RC within 100ft", ConditionalReaching, 10000, 9950, +500, true}, + {"RC 50ft past still climbing", ConditionalReaching, 10000, 10050, +500, true}, + {"RC 200ft short climbing", ConditionalReaching, 10000, 9800, +500, false}, + {"RC leveled at target", ConditionalReaching, 10000, 10000, 0, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + n := makeTestNav(t, 180) + n.FlightState.Altitude = tc.altitude + n.FlightState.AltitudeRate = tc.rate + pc := &PendingConditionalCommand{Kind: tc.kind, Altitude: tc.trigger} + if got := conditionalTriggered(&n, pc); got != tc.want { + t.Errorf("want %v got %v (kind=%v trigger=%v alt=%v rate=%v)", + tc.want, got, tc.kind, tc.trigger, tc.altitude, tc.rate) + } + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./nav/... -run TestConditionalTriggered -v` +Expected: FAIL — undefined `conditionalTriggered`. + +- [ ] **Step 3: Implement `conditionalTriggered`** + +Append to `nav/conditional.go`: + +```go +import "github.com/mmp/vice/math" // merge into existing imports + +// conditionalTriggered reports whether the pending conditional command +// should fire given the aircraft's current vertical state. +// +// ConditionalLeaving: fires when altitude is >50 ft past trigger in the +// direction of current vertical motion. +// ConditionalReaching: fires when altitude is within 100 ft of trigger. +func conditionalTriggered(nav *Nav, pc *PendingConditionalCommand) bool { + alt := nav.FlightState.Altitude + diff := alt - pc.Altitude + switch pc.Kind { + case ConditionalLeaving: + const leavingTol = 50.0 + if math.Abs(diff) <= leavingTol { + return false + } + rate := nav.FlightState.AltitudeRate + // Same-sign check: diff>0 (above trigger) requires rate>0 (climbing), + // diff<0 (below) requires rate<0 (descending). Zero rate with altitude + // drift outside tolerance (unusual but possible) is not a trigger. + return (diff > 0 && rate > 0) || (diff < 0 && rate < 0) + case ConditionalReaching: + const reachingTol = 100.0 + return math.Abs(diff) <= reachingTol + } + return false +} +``` + +Verify `nav.FlightState.AltitudeRate` exists: `grep -n "AltitudeRate" nav/nav.go`. If the field name differs (e.g., `VerticalRate`, `ClimbRate`), adjust. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./nav/... -run TestConditionalTriggered -v` +Expected: PASS (all subtests). + +- [ ] **Step 5: Commit** + +```bash +git add nav/conditional.go nav/conditional_test.go +git commit -m "nav: add conditionalTriggered predicate" +``` + +--- + +### Task 6: `triggerReachable` reachability check in sim + +**Files:** +- Modify: `sim/control.go` (add new helper function near other private helpers) +- Modify: `sim/control_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `sim/control_test.go` (append to the existing file — find a section with unit tests that don't need a full sim, or add a new one): + +```go +func TestTriggerReachable(t *testing.T) { + cases := []struct { + name string + kind nav.ConditionalKind + trigger float32 + current float32 + assigned *float32 + want bool + }{ + // LV: within 500ft slack even if direction is wrong + {"LV aircraft at 3050 climbing past", nav.ConditionalLeaving, 3000, 3050, floatp(5000), true}, + {"LV aircraft far past", nav.ConditionalLeaving, 3000, 5000, floatp(7000), false}, + {"LV trigger in path", nav.ConditionalLeaving, 3000, 1000, floatp(5000), true}, + {"LV no target, far from trigger", nav.ConditionalLeaving, 3000, 8000, nil, false}, + // RC: trigger must be between current and assigned target + {"RC target is trigger", nav.ConditionalReaching, 10000, 5000, floatp(10000), true}, + {"RC trigger above target", nav.ConditionalReaching, 12000, 5000, floatp(10000), false}, + {"RC no target but close", nav.ConditionalReaching, 10000, 9900, nil, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ac := &Aircraft{} // minimal; set up FlightState and Nav.Altitude + ac.Nav.FlightState.Altitude = tc.current + ac.Nav.Altitude.Assigned = tc.assigned + got := triggerReachable(ac, tc.kind, tc.trigger) + if got != tc.want { + t.Errorf("want %v got %v", tc.want, got) + } + }) + } +} + +func floatp(v float32) *float32 { return &v } +``` + +If a helper `floatp` (or similar) already exists in the test file, reuse it. Search: `grep -n "func floatp\|func fptr" sim/*_test.go`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./sim/... -run TestTriggerReachable -v` +Expected: FAIL — undefined `triggerReachable`. + +- [ ] **Step 3: Implement `triggerReachable`** + +Add to `sim/control.go` near the other conditional-command helpers (e.g., just above or below `parseSpeedUntil`): + +```go +// triggerReachable reports whether a LV/RC trigger altitude is +// reasonably reachable from the aircraft's current vertical state, +// allowing the controller command to be accepted. +// +// For ConditionalLeaving: accepted if the aircraft is within 500 ft of +// the trigger (so "leaving 3,000" works even for an aircraft at 3,050), +// or if the trigger lies between current altitude and assigned target. +// +// For ConditionalReaching: accepted if the trigger lies between current +// altitude and assigned target, or (if no target assigned) the aircraft +// is within 500 ft of the trigger. +func triggerReachable(ac *Aircraft, kind nav.ConditionalKind, trigger float32) bool { + cur := ac.Nav.FlightState.Altitude + target := ac.Nav.Altitude.Assigned + diff := math.Abs(cur - trigger) + switch kind { + case nav.ConditionalLeaving: + if diff <= 500 { + return true + } + if target == nil { + return false + } + return betweenAlt(trigger, cur, *target) + case nav.ConditionalReaching: + if target == nil { + return diff <= 500 + } + return betweenAlt(trigger, cur, *target) + } + return false +} + +// betweenAlt reports whether v lies between a and b (inclusive), in +// either ordering. +func betweenAlt(v, a, b float32) bool { + lo, hi := a, b + if lo > hi { + lo, hi = hi, lo + } + return v >= lo && v <= hi +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./sim/... -run TestTriggerReachable -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add sim/control.go sim/control_test.go +git commit -m "sim: add triggerReachable helper for LV/RC conditional commands" +``` + +--- + +### Task 7: `parseConditionalAltitude` + +**Files:** +- Modify: `sim/control.go` +- Modify: `sim/control_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `sim/control_test.go`: + +```go +func TestParseConditionalAltitude(t *testing.T) { + cases := []struct { + in string + want float32 + wantErr bool + }{ + {"30", 3000, false}, // hundreds-of-feet + {"130", 13000, false}, + {"100", 10000, false}, + {"1000", 1000, false}, // >600 && %100==0 → already feet + {"13000", 13000, false}, // ditto + {"", 0, true}, + {"abc", 0, true}, + } + for _, tc := range cases { + got, err := parseConditionalAltitude(tc.in) + if (err != nil) != tc.wantErr { + t.Errorf("parseConditionalAltitude(%q) err=%v wantErr=%v", tc.in, err, tc.wantErr) + continue + } + if !tc.wantErr && got != tc.want { + t.Errorf("parseConditionalAltitude(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./sim/... -run TestParseConditionalAltitude -v` +Expected: FAIL — undefined. + +- [ ] **Step 3: Implement `parseConditionalAltitude`** + +Add to `sim/control.go` near `triggerReachable`: + +```go +// parseConditionalAltitude parses the altitude-encoding convention used +// by LV/RC (and RR) commands: number × 100, with a carve-out for values +// that look like feet already (>600 and evenly divisible by 100). +func parseConditionalAltitude(s string) (float32, error) { + if s == "" { + return 0, ErrInvalidCommandSyntax + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, err + } + if n > 600 && n%100 == 0 { + return float32(n), nil + } + return float32(n * 100), nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./sim/... -run TestParseConditionalAltitude -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add sim/control.go sim/control_test.go +git commit -m "sim: add parseConditionalAltitude helper" +``` + +--- + +### Task 8: `parseConditionalAction` inner-command parser + +**Files:** +- Modify: `sim/control.go` +- Modify: `sim/control_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `sim/control_test.go`: + +```go +func TestParseConditionalAction(t *testing.T) { + cases := []struct { + in string + wantType string // type name of returned ConditionalAction + wantProps map[string]any + wantErr bool + }{ + {"H010", "ConditionalHeading", map[string]any{"Heading": 10, "Turn": av.TurnClosest}, false}, + {"L100", "ConditionalHeading", map[string]any{"Heading": 100, "Turn": av.TurnLeft}, false}, + {"R100", "ConditionalHeading", map[string]any{"Heading": 100, "Turn": av.TurnRight}, false}, + {"L20D", "ConditionalHeading", map[string]any{"ByDegrees": 20, "Turn": av.TurnLeft}, false}, + {"R30D", "ConditionalHeading", map[string]any{"ByDegrees": 30, "Turn": av.TurnRight}, false}, + {"DAAC", "ConditionalDirectFix", map[string]any{"Fix": "AAC", "Turn": av.TurnClosest}, false}, + {"LDAAC", "ConditionalDirectFix", map[string]any{"Fix": "AAC", "Turn": av.TurnLeft}, false}, + {"RDAAC", "ConditionalDirectFix", map[string]any{"Fix": "AAC", "Turn": av.TurnRight}, false}, + {"S210", "ConditionalSpeed", nil, false}, + {"M78", "ConditionalMach", map[string]any{"Mach": float32(0.78)}, false}, + + // Rejections: altitude-changing inners, unknowns, malformed + {"C50", "", nil, true}, + {"CVS", "", nil, true}, + {"DVS", "", nil, true}, + {"X010", "", nil, true}, + {"", "", nil, true}, + {"H", "", nil, true}, + {"HXYZ", "", nil, true}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + got, err := parseConditionalAction(tc.in) + if (err != nil) != tc.wantErr { + t.Fatalf("parseConditionalAction(%q) err=%v wantErr=%v", tc.in, err, tc.wantErr) + } + if tc.wantErr { + return + } + typeName := reflect.TypeOf(got).Name() + if typeName != tc.wantType { + t.Fatalf("parseConditionalAction(%q) type = %s, want %s", tc.in, typeName, tc.wantType) + } + // Property check via reflection + v := reflect.ValueOf(got) + for k, want := range tc.wantProps { + field := v.FieldByName(k) + if !field.IsValid() { + t.Errorf("no field %s on %s", k, typeName) + continue + } + if !reflect.DeepEqual(field.Interface(), want) { + t.Errorf("%s.%s = %v, want %v", typeName, k, field.Interface(), want) + } + } + }) + } +} +``` + +Add `reflect` to the test file's imports if not already present. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./sim/... -run TestParseConditionalAction -v` +Expected: FAIL — undefined. + +- [ ] **Step 3: Implement `parseConditionalAction`** + +Add to `sim/control.go` near the other conditional helpers: + +```go +// parseConditionalAction parses an inner command string (the right-hand +// side of LV/RC) into a typed ConditionalAction. Accepts only lateral and +// speed/mach actions; altitude-changing and unknown inners return +// ErrInvalidCommandSyntax. +// +// Grammar: +// H{hdg} → ConditionalHeading (closest turn) +// L{hdg} | R{hdg} → ConditionalHeading (left/right turn to heading) +// L{deg}D | R{deg}D → ConditionalHeading (turn N degrees) +// D{fix} → ConditionalDirectFix (closest) +// LD{fix} | RD{fix} → ConditionalDirectFix (left/right) +// S{spd} → ConditionalSpeed +// M{mach} → ConditionalMach (2-digit mach, e.g. M78 → 0.78) +func parseConditionalAction(s string) (nav.ConditionalAction, error) { + if len(s) < 2 { + return nil, ErrInvalidCommandSyntax + } + switch s[0] { + case 'H': + hdg, err := strconv.Atoi(s[1:]) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalHeading{Heading: hdg, Turn: av.TurnClosest}, nil + + case 'L', 'R': + turn := av.TurnLeft + if s[0] == 'R' { + turn = av.TurnRight + } + // LD{fix} / RD{fix} + if len(s) >= 5 && s[1] == 'D' { + return nav.ConditionalDirectFix{Fix: strings.ToUpper(s[2:]), Turn: turn}, nil + } + // LnnD / RnnD + if l := len(s); l > 2 && s[l-1] == 'D' { + deg, err := strconv.Atoi(s[1 : l-1]) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalHeading{ByDegrees: deg, Turn: turn}, nil + } + // L{hdg} / R{hdg} + hdg, err := strconv.Atoi(s[1:]) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalHeading{Heading: hdg, Turn: turn}, nil + + case 'D': + if len(s) < 4 { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalDirectFix{Fix: strings.ToUpper(s[1:]), Turn: av.TurnClosest}, nil + + case 'S': + sr, err := av.ParseSpeedRestriction(s[1:]) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalSpeed{Restriction: *sr}, nil + + case 'M': + if len(s) != 3 { + return nil, ErrInvalidCommandSyntax + } + mach, err := strconv.ParseFloat(s[1:], 32) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalMach{Mach: float32(mach) / 100.0}, nil + } + return nil, ErrInvalidCommandSyntax +} +``` + +Verify `av.ParseSpeedRestriction` exists and returns `*av.SpeedRestriction`: `grep -n "func ParseSpeedRestriction" aviation/*.go`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./sim/... -run TestParseConditionalAction -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add sim/control.go sim/control_test.go +git commit -m "sim: add parseConditionalAction for LV/RC inner commands" +``` + +--- + +### Task 9: `ConditionalCommandIntent` in aviation + +**Files:** +- Modify: `aviation/intent.go` +- Create or modify: `aviation/intent_test.go` (if a test file already exists for intents, use it) + +- [ ] **Step 1: Write the failing test** + +Find or create a test file for intent rendering. Search: `ls aviation/*_test.go`. If an existing test file covers intents (e.g., `intent_test.go`), append there; otherwise create `aviation/intent_test.go`. + +```go +// aviation/intent_test.go (append or create) +func TestConditionalCommandIntentRender(t *testing.T) { + // Use a simple stub action for testing the intent wrapper. + stub := stubConditionalAction{text: "fly heading 010"} + cases := []struct { + name string + kind ConditionalKind + alt float32 + want []string // substrings expected in the rendered output + }{ + {"leaving", ConditionalLeaving, 3000, []string{"leaving", "3", "fly heading 010"}}, + {"reaching", ConditionalReaching, 10000, []string{"reaching", "10", "fly heading 010"}}, + } + r := rand.New(rand.NewPCG(1, 2)) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + intent := ConditionalCommandIntent{Kind: tc.kind, Altitude: tc.alt, Action: stub} + rt := &RadioTransmission{} + intent.Render(rt, r) + written := strings.ToLower(rt.Written(r)) + for _, w := range tc.want { + if !strings.Contains(written, strings.ToLower(w)) { + t.Errorf("Render missing %q in %q", w, written) + } + } + }) + } +} + +type stubConditionalAction struct{ text string } + +func (s stubConditionalAction) Render(rt *RadioTransmission, r *rand.Rand) { + rt.Add(s.text) +} +// Execute not needed for this test — but if the interface requires it, +// make stub satisfy the full ConditionalAction interface. Note that +// aviation must not import nav (cycle risk); if ConditionalAction is +// defined in nav, the intent must reference the interface via a +// package-neutral declaration — see Step 3. +``` + +Note the import-cycle concern — `aviation` is a lower-level package than `nav` and cannot import from it. This means `ConditionalCommandIntent.Action` must NOT be typed as `nav.ConditionalAction`. Two options: + +(a) Declare a separate minimal interface in `aviation` — e.g., `type ConditionalActionRender interface { Render(*RadioTransmission, *rand.Rand) }`. The `nav.ConditionalAction` interface embeds both `Execute` and `Render`, so any `nav` action automatically satisfies the `aviation` render-only interface. Use this option. + +(b) Move the whole action type hierarchy down into `aviation` — more invasive; declines. + +The test's `stubConditionalAction` above implements only `Render`, which fits option (a). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./aviation/... -run TestConditionalCommandIntentRender -v` +Expected: FAIL — undefined. + +- [ ] **Step 3: Implement `ConditionalCommandIntent`** + +Add to `aviation/intent.go`, near the other special intents (e.g., after `ContactTowerIntent`): + +```go +// ConditionalKind in aviation mirrors the nav-package enum for use by +// ConditionalCommandIntent. Values must match nav.ConditionalKind. +type ConditionalKind uint8 + +const ( + ConditionalLeaving ConditionalKind = iota + ConditionalReaching +) + +// ConditionalActionRender is the subset of nav.ConditionalAction that +// the aviation-layer readback needs. Defined here to avoid an import +// cycle (nav imports aviation, not the other way around). +type ConditionalActionRender interface { + Render(rt *RadioTransmission, r *rand.Rand) +} + +// ConditionalCommandIntent is the readback for a "leaving/reaching {alt}, +// do X" command. It composes with the inner action's own Render so +// phraseology for H/L/R/D/S/M stays consistent with non-conditional +// variants. +type ConditionalCommandIntent struct { + Kind ConditionalKind + Altitude float32 + Action ConditionalActionRender +} + +func (c ConditionalCommandIntent) Render(rt *RadioTransmission, r *rand.Rand) { + switch c.Kind { + case ConditionalLeaving: + rt.Add("[leaving|passing] {alt}, ", c.Altitude) + case ConditionalReaching: + rt.Add("[reaching|level at|on reaching] {alt}, ", c.Altitude) + } + if c.Action != nil { + c.Action.Render(rt, r) + } +} +``` + +In `nav/conditional.go`, change `ConditionalKind` and the constants to **alias** the aviation ones to guarantee the values match: + +```go +type ConditionalKind = av.ConditionalKind + +const ( + ConditionalLeaving = av.ConditionalLeaving + ConditionalReaching = av.ConditionalReaching +) +``` + +Remove the old `ConditionalKind`, `ConditionalLeaving`, `ConditionalReaching` declarations from `nav/conditional.go`. The type alias (`=`) makes `nav.ConditionalKind` the same type as `av.ConditionalKind`, so existing code using either spelling compiles. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./aviation/... -run TestConditionalCommandIntentRender -v` +Expected: PASS. + +Run: `go test ./nav/...` +Expected: PASS (all prior tests still pass with aliased enum). + +- [ ] **Step 5: Commit** + +```bash +git add aviation/intent.go aviation/intent_test.go nav/conditional.go +git commit -m "aviation: add ConditionalCommandIntent; nav: alias enum to aviation" +``` + +--- + +### Task 10: `AssignConditional` sim method + +**Files:** +- Modify: `sim/control.go` +- Modify: `sim/control_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `sim/control_test.go`. Use the existing sim-test builder (search for how other sim-level commands like `ReportReaching` are tested — look at `sim/control_test.go` for a `setupTestSim` or `newTestSim` helper, and the pattern for `TestReportReaching` or similar). If no such pattern exists, build a minimal one using `NewSim` or the in-file test harness. + +```go +func TestAssignConditionalInstallsSlot(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000 /*alt*/, 7000 /*assigned*/) + action := nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest} + intent, err := s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, 3000, action) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if intent == nil { + t.Fatalf("expected non-nil intent") + } + ac := s.lookupAircraft(callsign) // whatever the test helper is + if ac.Nav.PendingConditionalCommand == nil { + t.Fatalf("expected PendingConditionalCommand installed") + } + if ac.Nav.PendingConditionalCommand.Altitude != 3000 { + t.Fatalf("wrong altitude: %v", ac.Nav.PendingConditionalCommand.Altitude) + } +} + +func TestAssignConditionalRejectsUnreachable(t *testing.T) { + // Aircraft at 5000, no assigned altitude change; trigger 3000 → unreachable. + s, callsign, tcw := setupTestSimWithAircraftAt(t, 5000, 5000) + action := nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest} + _, err := s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, 3000, action) + if err == nil { + t.Fatalf("expected error for unreachable trigger, got nil") + } +} + +func TestAssignConditionalSupersedes(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) + first := nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest} + second := nav.ConditionalDirectFix{Fix: "AAC", Turn: av.TurnClosest} + _, _ = s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, 3000, first) + _, _ = s.AssignConditional(tcw, callsign, nav.ConditionalReaching, 6000, second) + ac := s.lookupAircraft(callsign) + pc := ac.Nav.PendingConditionalCommand + if pc == nil || pc.Kind != nav.ConditionalReaching || pc.Altitude != 6000 { + t.Fatalf("expected superseded slot: reaching 6000, got %+v", pc) + } +} +``` + +If the helpers `setupTestSimWithAircraftAt` and `lookupAircraft` don't exist, adapt to whatever the existing test file uses — look at the nearest `TestAssign*` function in `sim/control_test.go` for the pattern. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./sim/... -run TestAssignConditional -v` +Expected: FAIL — undefined method. + +- [ ] **Step 3: Implement `AssignConditional`** + +Add to `sim/control.go`, near `ReportReaching` (which is at roughly line 320 per commit `347d0085`): + +```go +// AssignConditional installs a deferred LV/RC action on the aircraft's +// nav state. The action fires silently when sim.updateState observes +// the altitude trigger. Returns an error if the trigger is not +// reachable from the aircraft's current vertical state. +func (s *Sim) AssignConditional(tcw TCW, callsign av.ADSBCallsign, + kind nav.ConditionalKind, altitude float32, action nav.ConditionalAction) (av.CommandIntent, error) { + + s.mu.Lock(s.lg) + defer s.mu.Unlock(s.lg) + + return s.dispatchControlledAircraftCommand(tcw, callsign, + func(tcw TCW, ac *Aircraft) av.CommandIntent { + if !triggerReachable(ac, kind, altitude) { + return av.MakeUnableIntent("unable. %s is out of our climb/descent path.", + av.FormatAltitude(altitude)) + } + ac.Nav.PendingConditionalCommand = &nav.PendingConditionalCommand{ + Kind: kind, + Altitude: altitude, + Action: action, + } + return av.ConditionalCommandIntent{ + Kind: kind, + Altitude: altitude, + Action: action, + } + }) +} +``` + +`av.FormatAltitude` should already exist (used throughout the codebase). `av.MakeUnableIntent` exists in aviation (used at `nav/commands.go:41`). + +Note: `dispatchControlledAircraftCommand` expects the callback to return a `CommandIntent`, not an error. Reachability rejection becomes an unable-intent here (same convention as "that altitude is above our ceiling" in nav/commands.go:41). The outer `(intent, error)` return of the method path is for lookup errors (no such aircraft), not logical "unable" cases. + +Review this decision against the Q4 design intent — "Reject with error." An unable-intent is how the rest of the sim signals unable; tests should assert on the intent type and message rather than `err != nil`. Adjust `TestAssignConditionalRejectsUnreachable` accordingly: + +```go +func TestAssignConditionalRejectsUnreachable(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 5000, 5000) + action := nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest} + intent, err := s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, 3000, action) + if err != nil { + t.Fatalf("dispatch error: %v", err) + } + if _, ok := intent.(av.UnableIntent); !ok { + t.Fatalf("expected UnableIntent for unreachable trigger, got %T", intent) + } + ac := s.lookupAircraft(callsign) + if ac.Nav.PendingConditionalCommand != nil { + t.Fatalf("expected no slot installed for unable") + } +} +``` + +Check the actual unable-intent type name: `grep -n "type UnableIntent\|type.*Unable" aviation/*.go`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./sim/... -run TestAssignConditional -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add sim/control.go sim/control_test.go +git commit -m "sim: add AssignConditional method for LV/RC commands" +``` + +--- + +### Task 11: Dispatch branch for `LV` in case 'L' + +**Files:** +- Modify: `sim/control.go` +- Modify: `sim/control_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `sim/control_test.go`: + +```go +func TestRunControlCommandLV(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) + intent, err := s.runOneControlCommand(tcw, callsign, "LV30/H010", 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := intent.(av.ConditionalCommandIntent); !ok { + t.Fatalf("expected ConditionalCommandIntent, got %T", intent) + } + ac := s.lookupAircraft(callsign) + if ac.Nav.PendingConditionalCommand == nil { + t.Fatalf("slot not installed") + } + if ac.Nav.PendingConditionalCommand.Altitude != 3000 { + t.Fatalf("wrong altitude %v", ac.Nav.PendingConditionalCommand.Altitude) + } +} + +func TestRunControlCommandLVRejectsMalformed(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) + cases := []string{ + "LV30H010", // missing slash + "LV/H010", // empty altitude + "LV30/", // empty inner + "LVABC/H010", // non-numeric altitude + "LV30/C50", // altitude-changing inner + "LV30/X010", // unknown inner + } + for _, cmd := range cases { + t.Run(cmd, func(t *testing.T) { + _, err := s.runOneControlCommand(tcw, callsign, cmd, 0) + if err == nil { + t.Fatalf("expected error for %q, got nil", cmd) + } + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./sim/... -run TestRunControlCommandLV -v` +Expected: FAIL (LV command not yet handled — falls through to existing heading parse, which will fail weirdly). + +- [ ] **Step 3: Add the LV branch in case 'L'** + +In `sim/control.go`, in `runOneControlCommand`, locate `case 'L':` (around line 4096 per the pre-branch state; confirm with `grep -n "case 'L':" sim/control.go`). Insert the following branch BEFORE any existing branches in `case 'L'`: + +```go +case 'L': + if strings.HasPrefix(command, "LV") && len(command) > 2 { + altStr, inner, ok := strings.Cut(command[2:], "/") + if !ok || altStr == "" || inner == "" { + return nil, ErrInvalidCommandSyntax + } + alt, err := parseConditionalAltitude(altStr) + if err != nil { + return nil, err + } + action, err := parseConditionalAction(inner) + if err != nil { + return nil, err + } + return s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, alt, action) + } + // ...existing case 'L' body unchanged... +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./sim/... -run TestRunControlCommandLV -v` +Expected: PASS. + +Run the full sim suite to ensure no regression: `go test ./sim/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add sim/control.go sim/control_test.go +git commit -m "sim: dispatch LV{alt}/{inner} as conditional-leaving command" +``` + +--- + +### Task 12: Dispatch branch for `RC` in case 'R' + +**Files:** +- Modify: `sim/control.go` +- Modify: `sim/control_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `sim/control_test.go`: + +```go +func TestRunControlCommandRC(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 5000, 10000) + intent, err := s.runOneControlCommand(tcw, callsign, "RC100/DAAC", 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := intent.(av.ConditionalCommandIntent); !ok { + t.Fatalf("expected ConditionalCommandIntent, got %T", intent) + } + ac := s.lookupAircraft(callsign) + if ac.Nav.PendingConditionalCommand == nil { + t.Fatalf("slot not installed") + } + if ac.Nav.PendingConditionalCommand.Altitude != 10000 { + t.Fatalf("wrong altitude %v", ac.Nav.PendingConditionalCommand.Altitude) + } +} + +func TestRunControlCommandRCDoesNotConflictWithRR(t *testing.T) { + // Ensure RC100 is not parsed as RR (report reaching). + s, callsign, tcw := setupTestSimWithAircraftAt(t, 5000, 10000) + intent, err := s.runOneControlCommand(tcw, callsign, "RC100/H010", 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := intent.(av.ConditionalCommandIntent); !ok { + t.Fatalf("expected ConditionalCommandIntent, got %T", intent) + } + ac := s.lookupAircraft(callsign) + // RR would have set ReportReachingAltitude, not PendingConditionalCommand. + if ac.Nav.ReportReachingAltitude != nil { + t.Fatalf("RR altitude should not be set, got %v", *ac.Nav.ReportReachingAltitude) + } + if ac.Nav.PendingConditionalCommand == nil { + t.Fatalf("conditional slot not installed") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./sim/... -run TestRunControlCommandRC -v` +Expected: FAIL. + +- [ ] **Step 3: Add the RC branch in case 'R'** + +In `sim/control.go`, in `runOneControlCommand`, locate `case 'R':` (around line 4139 in the pre-branch state; confirm with `grep -n "case 'R':" sim/control.go`). Insert a new branch BEFORE the existing `RR` branch: + +```go +case 'R': + if command == "RON" { + return s.ResumeOwnNavigation(tcw, callsign) + } else if command == "RST" { + return s.RadarServicesTerminated(tcw, callsign) + } else if strings.HasPrefix(command, "RC") && len(command) > 2 && strings.Contains(command, "/") { + altStr, inner, ok := strings.Cut(command[2:], "/") + if !ok || altStr == "" || inner == "" { + return nil, ErrInvalidCommandSyntax + } + alt, err := parseConditionalAltitude(altStr) + if err != nil { + return nil, err + } + action, err := parseConditionalAction(inner) + if err != nil { + return nil, err + } + return s.AssignConditional(tcw, callsign, nav.ConditionalReaching, alt, action) + } else if strings.HasPrefix(command, "RR") && len(command) > 2 && util.IsAllNumbers(command[2:]) { + // ...existing RR branch body unchanged... + } + // ...remainder of case 'R' unchanged... +``` + +The `strings.Contains(command, "/")` guard on RC disambiguates from any future `RC` pattern; the slash is mandatory for the conditional syntax. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./sim/... -run TestRunControlCommandRC -v` +Expected: PASS. + +Run: `go test ./sim/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add sim/control.go sim/control_test.go +git commit -m "sim: dispatch RC{alt}/{inner} as conditional-reaching command" +``` + +--- + +### Task 13: Trigger firing in `sim.updateState` + +**Files:** +- Modify: `sim/sim.go` +- Modify: `sim/e2e_test.go` (or create `sim/conditional_e2e_test.go`) + +- [ ] **Step 1: Write the failing test** + +Create `sim/conditional_e2e_test.go`. Model after the existing e2e tests in `sim/e2e_test.go` — read that file for the pattern (sim setup, tick loop, assertions). + +```go +package sim + +import ( + "testing" + + av "github.com/mmp/vice/aviation" + "github.com/mmp/vice/nav" +) + +func TestLVHeadingE2E(t *testing.T) { + s, callsign, tcw := setupE2ESimClimbing(t, 2000 /*from*/, 7000 /*to*/) + // Leaving 3,000 → turn left 010 + _, err := s.runOneControlCommand(tcw, callsign, "LV30/L010", 0) + if err != nil { + t.Fatalf("command error: %v", err) + } + // Tick until aircraft is >50 ft past 3,000 climbing. + s.tickUntil(t, func(ac *Aircraft) bool { + return ac.Nav.FlightState.Altitude > 3100 + }, 300 /*tick budget*/) + + ac := s.lookupAircraft(callsign) + if ac.Nav.PendingConditionalCommand != nil { + t.Fatalf("slot not cleared after trigger fire") + } + if hdg, ok := ac.Nav.AssignedHeading(); !ok || hdg != 10 { + t.Fatalf("expected assigned heading 10, got ok=%v hdg=%v", ok, hdg) + } +} + +func TestRCDirectFixE2E(t *testing.T) { + s, callsign, tcw := setupE2ESimClimbingWithFix(t, 7000, 10000, "AAC") + _, err := s.runOneControlCommand(tcw, callsign, "RC100/DAAC", 0) + if err != nil { + t.Fatalf("command error: %v", err) + } + s.tickUntil(t, func(ac *Aircraft) bool { + return ac.Nav.FlightState.Altitude >= 9900 + }, 600) + ac := s.lookupAircraft(callsign) + if ac.Nav.PendingConditionalCommand != nil { + t.Fatalf("slot not cleared") + } + if len(ac.Nav.Waypoints) == 0 || ac.Nav.Waypoints[0].Fix != "AAC" { + t.Fatalf("expected direct AAC, got waypoints %+v", ac.Nav.Waypoints) + } +} +``` + +If helpers like `setupE2ESimClimbing`, `tickUntil`, `lookupAircraft` don't exist, create them (likely small wrappers around existing test utilities). Look at `sim/altimeter_integration_test.go` which was added for a similar end-to-end verification — it's the closest precedent. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./sim/... -run "TestLVHeadingE2E|TestRCDirectFixE2E" -v` +Expected: FAIL — the command installs the slot, but no trigger-firing code exists yet, so the slot stays and heading/waypoint isn't updated. + +- [ ] **Step 3: Add trigger-firing code to `sim.updateState`** + +In `sim/sim.go`, in `Sim.updateState`, near the existing `ReportReachingAltitude` check (added in commit `347d0085`), insert: + +```go +// "Leaving/reaching {alt}, do X" — when the aircraft crosses the +// trigger altitude, silently execute the deferred action. The slot +// is cleared BEFORE Execute runs so a mis-parsed inner command that +// installs another conditional doesn't loop. +if pc := ac.Nav.PendingConditionalCommand; pc != nil && ac.IsAssociated() { + if nav.ConditionalTriggered(&ac.Nav, pc) { + action := pc.Action + ac.Nav.PendingConditionalCommand = nil + action.Execute(&ac.Nav, s.State.SimTime) + } +} +``` + +Note: `conditionalTriggered` from Task 5 was private (lowercase). Export it as `ConditionalTriggered` (capitalize the first letter in `nav/conditional.go`) so `sim` can call it. Update the Task 5 test to use the uppercase name. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./sim/... -run "TestLVHeadingE2E|TestRCDirectFixE2E" -v` +Expected: PASS. + +Run all tests: `go test ./sim/... ./nav/... ./aviation/... ./stt/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add sim/sim.go sim/conditional_e2e_test.go nav/conditional.go nav/conditional_test.go +git commit -m "sim: fire LV/RC conditional commands when altitude trigger is met" +``` + +--- + +### Task 14: STT grammar for `LV` trigger + +**Files:** +- Modify: `stt/handlers.go` +- Modify: `stt/handlers_test.go` + +- [ ] **Step 1: Read the STT framework first** + +Run: `grep -n "func registerSTTCommand\|type CommandOption" stt/registry.go` — get the exact signature. + +Examine an existing multi-slot command for the argument-binding pattern (e.g., how the `report reaching {altitude}` handler at commit `347d0085` binds `alt` to the handler parameter). Also examine a command that includes a turn direction (e.g., "turn left heading {hdg}") for how multi-token matches produce the command string. + +- [ ] **Step 2: Write the failing test** + +Append to `stt/handlers_test.go`. Look at existing tests (e.g., for "report reaching {altitude}") for the assertion pattern — probably `parseAndMatch(input)` returns the command string. + +```go +func TestSTTLeavingPatterns(t *testing.T) { + cases := []struct { + spoken string + want string // expected command string + }{ + {"leaving three thousand fly heading zero one zero", "LV30/H010"}, + {"passing one three thousand right heading one zero zero", "LV130/R100"}, + {"leaving five thousand turn left heading two seven zero", "LV50/L270"}, + {"leaving three thousand turn left twenty degrees", "LV30/L20D"}, + {"leaving three thousand direct alpha alpha charlie", "LV30/DAAC"}, + {"leaving five thousand reduce speed to two one zero", "LV50/S210"}, + } + for _, tc := range cases { + t.Run(tc.spoken, func(t *testing.T) { + got := matchSTT(tc.spoken) // existing test helper (or equivalent) + if got != tc.want { + t.Errorf("matchSTT(%q) = %q, want %q", tc.spoken, got, tc.want) + } + }) + } +} +``` + +If there's no existing `matchSTT` helper, search `stt/*_test.go` for how other tests exercise the grammar — the API is probably a method on a registry object. + +- [ ] **Step 3: Run test to verify it fails** + +Run: `go test ./stt/... -run TestSTTLeavingPatterns -v` +Expected: FAIL — no matching grammar registered. + +- [ ] **Step 4: Register the LV grammar** + +In `stt/handlers.go`, inside the existing `registerAllCommands` function, add a section for conditional-leaving patterns. Because the inner-command grammar is already defined elsewhere, we register one `registerSTTCommand` per (trigger × inner) combination. Keep each inner's phraseology consistent with the non-conditional version of the same command: + +```go +// "Leaving/passing {alt}, {inner}" — conditional LV commands. + +// LV/H{hdg}: "leaving three thousand, fly heading 010" +registerSTTCommand( + "leaving|passing {altitude}, fly heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("LV%d/H%03d", alt, hdg) }, + WithName("conditional_lv_heading"), + WithPriority(11), +) + +// LV/L{hdg} and LV/R{hdg} +registerSTTCommand( + "leaving|passing {altitude}, turn left heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("LV%d/L%03d", alt, hdg) }, + WithName("conditional_lv_turn_left_heading"), + WithPriority(11), +) +registerSTTCommand( + "leaving|passing {altitude}, turn right heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("LV%d/R%03d", alt, hdg) }, + WithName("conditional_lv_turn_right_heading"), + WithPriority(11), +) + +// LV/L{deg}D and LV/R{deg}D +registerSTTCommand( + "leaving|passing {altitude}, turn left {num:1-180} degrees", + func(alt int, deg int) string { return fmt.Sprintf("LV%d/L%dD", alt, deg) }, + WithName("conditional_lv_turn_left_degrees"), + WithPriority(11), +) +registerSTTCommand( + "leaving|passing {altitude}, turn right {num:1-180} degrees", + func(alt int, deg int) string { return fmt.Sprintf("LV%d/R%dD", alt, deg) }, + WithName("conditional_lv_turn_right_degrees"), + WithPriority(11), +) + +// LV/D{fix}, LV/LD{fix}, LV/RD{fix} +registerSTTCommand( + "leaving|passing {altitude}, [proceed] direct {fix}", + func(alt int, fix string) string { return fmt.Sprintf("LV%d/D%s", alt, fix) }, + WithName("conditional_lv_direct"), + WithPriority(11), +) +registerSTTCommand( + "leaving|passing {altitude}, turn left direct {fix}", + func(alt int, fix string) string { return fmt.Sprintf("LV%d/LD%s", alt, fix) }, + WithName("conditional_lv_left_direct"), + WithPriority(11), +) +registerSTTCommand( + "leaving|passing {altitude}, turn right direct {fix}", + func(alt int, fix string) string { return fmt.Sprintf("LV%d/RD%s", alt, fix) }, + WithName("conditional_lv_right_direct"), + WithPriority(11), +) + +// LV/S{spd} +registerSTTCommand( + "leaving|passing {altitude}, [reduce speed to|maintain|slow to] {speed}", + func(alt int, spd int) string { return fmt.Sprintf("LV%d/S%d", alt, spd) }, + WithName("conditional_lv_speed"), + WithPriority(11), +) + +// LV/M{mach} +registerSTTCommand( + "leaving|passing {altitude}, [maintain] mach {num:50-99}", + func(alt int, mach int) string { return fmt.Sprintf("LV%d/M%d", alt, mach) }, + WithName("conditional_lv_mach"), + WithPriority(11), +) +``` + +Verify the template syntax matches the existing STT framework — read `stt/handlers.go` around the commit `347d0085` additions for precedent. The brackets `[...]` for optional tokens, pipes `|` for alternation, and `{name:range}` for constrained numbers are all inferred from the `stop_altitude_squawk_with_delta` command added in that commit. If the framework's token syntax differs, adjust. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./stt/... -run TestSTTLeavingPatterns -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add stt/handlers.go stt/handlers_test.go +git commit -m "stt: add LV conditional-leaving voice patterns" +``` + +--- + +### Task 15: STT grammar for `RC` trigger + +**Files:** +- Modify: `stt/handlers.go` +- Modify: `stt/handlers_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `stt/handlers_test.go`: + +```go +func TestSTTReachingPatterns(t *testing.T) { + cases := []struct { + spoken string + want string + }{ + {"reaching one zero thousand fly heading zero one zero", "RC100/H010"}, + {"level at one zero thousand direct alpha alpha charlie", "RC100/DAAC"}, + {"on reaching five thousand reduce speed to two one zero", "RC50/S210"}, + {"reaching three five zero mach seven eight", "RC350/M78"}, + } + for _, tc := range cases { + t.Run(tc.spoken, func(t *testing.T) { + got := matchSTT(tc.spoken) + if got != tc.want { + t.Errorf("matchSTT(%q) = %q, want %q", tc.spoken, got, tc.want) + } + }) + } +} + +func TestSTTReachingDoesNotMatchReportReaching(t *testing.T) { + // "report reaching {alt}" must still route to the RR command, not RC. + got := matchSTT("report reaching one zero thousand") + if got != "RR100" { + t.Errorf("expected RR100 for report reaching, got %q", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./stt/... -run TestSTTReaching -v` +Expected: FAIL. + +- [ ] **Step 3: Register the RC grammar** + +In `stt/handlers.go`, add a parallel section to Task 14 but with the reaching triggers. Key differences: + +- Triggers: `"reaching|level at|on reaching {altitude}"`. +- **Avoid `"report reaching"` overlap**: the existing `report_reaching` handler at commit `347d0085` has priority 10. Set RC handlers to priority 11 (higher priority, but `report reaching` explicitly starts with `report`, so the prefix difference should be disambiguating). Add a test (already in Step 1) confirming the existing `report reaching` grammar still wins for its phrasing. + +```go +// "Reaching/level at/on reaching {alt}, {inner}" — conditional RC commands. + +registerSTTCommand( + "reaching|level at|on reaching {altitude}, fly heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/H%03d", alt, hdg) }, + WithName("conditional_rc_heading"), + WithPriority(11), +) +registerSTTCommand( + "reaching|level at|on reaching {altitude}, turn left heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/L%03d", alt, hdg) }, + WithName("conditional_rc_turn_left_heading"), + WithPriority(11), +) +registerSTTCommand( + "reaching|level at|on reaching {altitude}, turn right heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/R%03d", alt, hdg) }, + WithName("conditional_rc_turn_right_heading"), + WithPriority(11), +) +registerSTTCommand( + "reaching|level at|on reaching {altitude}, turn left {num:1-180} degrees", + func(alt int, deg int) string { return fmt.Sprintf("RC%d/L%dD", alt, deg) }, + WithName("conditional_rc_turn_left_degrees"), + WithPriority(11), +) +registerSTTCommand( + "reaching|level at|on reaching {altitude}, turn right {num:1-180} degrees", + func(alt int, deg int) string { return fmt.Sprintf("RC%d/R%dD", alt, deg) }, + WithName("conditional_rc_turn_right_degrees"), + WithPriority(11), +) +registerSTTCommand( + "reaching|level at|on reaching {altitude}, [proceed] direct {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/D%s", alt, fix) }, + WithName("conditional_rc_direct"), + WithPriority(11), +) +registerSTTCommand( + "reaching|level at|on reaching {altitude}, turn left direct {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/LD%s", alt, fix) }, + WithName("conditional_rc_left_direct"), + WithPriority(11), +) +registerSTTCommand( + "reaching|level at|on reaching {altitude}, turn right direct {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/RD%s", alt, fix) }, + WithName("conditional_rc_right_direct"), + WithPriority(11), +) +registerSTTCommand( + "reaching|level at|on reaching {altitude}, [reduce speed to|maintain|slow to] {speed}", + func(alt int, spd int) string { return fmt.Sprintf("RC%d/S%d", alt, spd) }, + WithName("conditional_rc_speed"), + WithPriority(11), +) +registerSTTCommand( + "reaching|level at|on reaching {altitude}, [maintain] mach {num:50-99}", + func(alt int, mach int) string { return fmt.Sprintf("RC%d/M%d", alt, mach) }, + WithName("conditional_rc_mach"), + WithPriority(11), +) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./stt/... -run "TestSTTReaching|TestSTTLeaving" -v` +Expected: PASS. + +Full test run: `go test ./...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add stt/handlers.go stt/handlers_test.go +git commit -m "stt: add RC conditional-reaching voice patterns" +``` + +--- + +### Task 16: whatsnew.md entry + +**Files:** +- Modify: `whatsnew.md` + +- [ ] **Step 1: Read the existing whatsnew format** + +Run: `head -30 whatsnew.md` — see the format for recent entries (bullet list, terse, user-facing). + +- [ ] **Step 2: Add the entry** + +Add a single bullet near the top of `whatsnew.md` (or in the most-recent-changes section, whatever the existing convention is): + +```markdown +- Added "leaving/reaching {altitude}, {action}" controller commands. Examples: `LV30/H010` ("leaving 3,000, fly heading 010"), `RC100/DAAC` ("reaching 10,000, direct AAC"). Supported inner actions: headings, turns by degrees, direct-to-fix, speed, and mach. +``` + +- [ ] **Step 3: Commit** + +```bash +git add whatsnew.md +git commit -m "docs: whatsnew entry for LV/RC conditional commands" +``` + +--- + +### Task 17: Final full-suite verification + +- [ ] **Step 1: Run everything** + +```bash +go test ./... +``` + +Expected: PASS. + +- [ ] **Step 2: Spot-check the feature end-to-end in a running sim (optional but recommended)** + +If time permits, launch vice, spawn a scenario with a departure climbing out, and issue `LV30/H010` via keyboard. Verify the readback renders "leaving 3,000, fly heading 010" and that the heading turn happens silently once the aircraft climbs through 3,000. Repeat for `RC100/DAAC` with an arrival descending to 10,000. + +- [ ] **Step 3: No commit** — this is verification only. + +--- + +## Spec coverage self-check + +Walking the spec section by section: + +| Spec section | Covered by | +|---|---| +| Data model (`PendingConditionalCommand`, `ConditionalAction`) | Task 1 | +| `ConditionalHeading` | Task 2 | +| `ConditionalDirectFix` | Task 3 | +| `ConditionalSpeed` + `ConditionalMach` | Task 4 | +| Trigger predicate | Task 5 | +| Reachability rule | Task 6 | +| Altitude encoding | Task 7 | +| Inner-command parser | Task 8 | +| Readback intent | Task 9 | +| `AssignConditional` sim method | Task 10 | +| `LV` dispatch | Task 11 | +| `RC` dispatch | Task 12 | +| Silent firing at trigger | Task 13 | +| STT voice (LV) | Task 14 | +| STT voice (RC) | Task 15 | +| User-visible changelog | Task 16 | +| Final regression check | Task 17 | + +No spec items unaccounted for. + +## Open verification notes + +These are items I've flagged in the plan that require a small amount of framework archaeology at implementation time — they don't change the design but affect the exact code: + +1. **STT template syntax** (Task 14 step 4) — the `{num:N-M}` range syntax and `[optional]` token delimiter are inferred from the existing `stop_altitude_squawk_with_delta` handler. If the framework uses different syntax, adjust the templates at that step and add a smaller-scope test to verify. + +2. **Temperature access in `ConditionalMach.Execute`** (Task 4 step 3) — if `FlightState.Temperature` doesn't exist, extend `ConditionalAction.Execute` with a `temp av.Temperature` parameter and have sim.updateState look it up via the weather model before calling. + +3. **`directFix` internal helper** (Task 3 step 3) — if no lowercase internal helper exists on `Nav`, use the public `DirectFix` and discard the returned intent. + +4. **`UnableIntent` naming** (Task 10 step 3) — confirm the exact type name of the unable-intent used by `av.MakeUnableIntent`. From e67dddbab59848aaa3932e5d3d1456a6e7a5fe03 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:13:15 -0400 Subject: [PATCH 03/26] nav: add ConditionalAction interface and PendingConditionalCommand slot Co-Authored-By: Claude Opus 4.7 --- nav/conditional.go | 47 +++++++++++++++++++++++++++++++++++++++++ nav/conditional_test.go | 22 +++++++++++++++++++ nav/nav.go | 6 ++++++ 3 files changed, 75 insertions(+) create mode 100644 nav/conditional.go create mode 100644 nav/conditional_test.go diff --git a/nav/conditional.go b/nav/conditional.go new file mode 100644 index 000000000..235f6e68e --- /dev/null +++ b/nav/conditional.go @@ -0,0 +1,47 @@ +// nav/conditional.go +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package nav + +import ( + "math/rand/v2" + + av "github.com/mmp/vice/aviation" +) + +// ConditionalKind identifies which altitude-event triggers the deferred action. +type ConditionalKind uint8 + +const ( + // ConditionalLeaving fires once the aircraft's altitude has passed the + // trigger by more than a small tolerance in the direction of current + // vertical motion. + ConditionalLeaving ConditionalKind = iota + + // ConditionalReaching fires on first contact within 100 ft of the trigger + // altitude, regardless of vertical rate. + ConditionalReaching +) + +// ConditionalAction is the deferred action to execute when a LV/RC trigger +// fires. Concrete types cover the closed set of supported inner commands +// (heading, direct-fix, speed, mach). +type ConditionalAction interface { + // Execute mutates nav to carry out the deferred action. Called with the + // PendingConditionalCommand slot already cleared, so re-entry is safe. + Execute(nav *Nav, simTime Time) + + // Render emits the action-specific readback fragment (e.g., "fly heading + // 010") used inside ConditionalCommandIntent. + Render(rt *av.RadioTransmission, r *rand.Rand) +} + +// PendingConditionalCommand is the single slot on Nav that stores a +// deferred LV/RC action. A new LV/RC command supersedes any prior slot; +// successful trigger firing clears it. +type PendingConditionalCommand struct { + Kind ConditionalKind + Altitude float32 // feet MSL + Action ConditionalAction +} diff --git a/nav/conditional_test.go b/nav/conditional_test.go new file mode 100644 index 000000000..b95652898 --- /dev/null +++ b/nav/conditional_test.go @@ -0,0 +1,22 @@ +package nav + +import ( + "testing" +) + +func TestNavHasPendingConditionalCommandField(t *testing.T) { + var n Nav + if n.PendingConditionalCommand != nil { + t.Fatalf("PendingConditionalCommand should default to nil, got %+v", n.PendingConditionalCommand) + } + n.PendingConditionalCommand = &PendingConditionalCommand{ + Kind: ConditionalLeaving, + Altitude: 3000, + } + if n.PendingConditionalCommand.Kind != ConditionalLeaving { + t.Fatalf("expected ConditionalLeaving, got %d", n.PendingConditionalCommand.Kind) + } + if n.PendingConditionalCommand.Altitude != 3000 { + t.Fatalf("expected 3000, got %v", n.PendingConditionalCommand.Altitude) + } +} diff --git a/nav/nav.go b/nav/nav.go index ef898c011..3653ed78f 100644 --- a/nav/nav.go +++ b/nav/nav.go @@ -68,6 +68,12 @@ type Nav struct { PendingWaypointActionEvents []av.WaypointActionEvent Rand *rand.Rand + + // PendingConditionalCommand stores a single deferred LV/RC action + // (e.g., "leaving 3,000, fly heading 010"). Cleared when the trigger + // fires or when a new LV/RC command is installed. Not cleared on + // new altitude/heading/speed assignments or on handoff. + PendingConditionalCommand *PendingConditionalCommand } type UpdateResult struct { From 6f2b9812d4bc312c75ac8d37bf9be9fdc315e8fa Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:22:21 -0400 Subject: [PATCH 04/26] nav: add ConditionalHeading action with Execute and Render Co-Authored-By: Claude Opus 4.7 --- nav/conditional.go | 52 ++++++++++++++++++++++++++++++++++++-- nav/conditional_test.go | 55 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/nav/conditional.go b/nav/conditional.go index 235f6e68e..528037212 100644 --- a/nav/conditional.go +++ b/nav/conditional.go @@ -5,9 +5,9 @@ package nav import ( - "math/rand/v2" - av "github.com/mmp/vice/aviation" + vmath "github.com/mmp/vice/math" + "github.com/mmp/vice/rand" ) // ConditionalKind identifies which altitude-event triggers the deferred action. @@ -45,3 +45,51 @@ type PendingConditionalCommand struct { Altitude float32 // feet MSL Action ConditionalAction } + +// ConditionalHeading is a deferred heading assignment. Exactly one of +// Heading or ByDegrees is nonzero: +// - Heading != 0 → fly (or turn to) the absolute heading. +// - ByDegrees != 0 → turn N degrees from present heading in the given +// direction (Turn must be TurnLeft or TurnRight). +type ConditionalHeading struct { + Heading int // 1..360, 0 if unused + Turn av.TurnDirection // TurnClosest, TurnLeft, TurnRight + ByDegrees int // nonzero for LnnD / RnnD +} + +func (c ConditionalHeading) Execute(nav *Nav, simTime Time) { + if c.ByDegrees != 0 { + switch c.Turn { + case av.TurnLeft: + nav.assignHeading( + vmath.OffsetHeading(nav.FlightState.Heading, float32(-c.ByDegrees)), + av.TurnLeft, simTime, 0) + case av.TurnRight: + nav.assignHeading( + vmath.OffsetHeading(nav.FlightState.Heading, float32(c.ByDegrees)), + av.TurnRight, simTime, 0) + } + return + } + nav.assignHeading(vmath.MagneticHeading(c.Heading), c.Turn, simTime, 0) +} + +func (c ConditionalHeading) Render(rt *av.RadioTransmission, r *rand.Rand) { + if c.ByDegrees != 0 { + switch c.Turn { + case av.TurnLeft: + rt.Add("[left|turn left] {num} degrees", c.ByDegrees) + case av.TurnRight: + rt.Add("[right|turn right] {num} degrees", c.ByDegrees) + } + return + } + switch c.Turn { + case av.TurnLeft: + rt.Add("[left heading|turn left heading] {hdg}", c.Heading) + case av.TurnRight: + rt.Add("[right heading|turn right heading] {hdg}", c.Heading) + default: + rt.Add("[fly heading|heading] {hdg}", c.Heading) + } +} diff --git a/nav/conditional_test.go b/nav/conditional_test.go index b95652898..169976e88 100644 --- a/nav/conditional_test.go +++ b/nav/conditional_test.go @@ -1,7 +1,12 @@ package nav import ( + "strings" "testing" + + av "github.com/mmp/vice/aviation" + vmath "github.com/mmp/vice/math" + vrand "github.com/mmp/vice/rand" ) func TestNavHasPendingConditionalCommandField(t *testing.T) { @@ -20,3 +25,53 @@ func TestNavHasPendingConditionalCommandField(t *testing.T) { t.Fatalf("expected 3000, got %v", n.PendingConditionalCommand.Altitude) } } + +func TestConditionalHeadingExecuteClosest(t *testing.T) { + n := makeTestNav(t, 180) + action := ConditionalHeading{Heading: 10, Turn: av.TurnClosest} + action.Execute(&n, Time{}) + if assigned, ok := n.AssignedHeading(); !ok || assigned != 10 { + t.Fatalf("expected assigned heading 10, got ok=%v heading=%v", ok, assigned) + } +} + +func TestConditionalHeadingExecuteByDegreesLeft(t *testing.T) { + n := makeTestNav(t, 180) + action := ConditionalHeading{ByDegrees: 30, Turn: av.TurnLeft} + action.Execute(&n, Time{}) + // TurnLeft 30 from 180 -> 150 + if assigned, ok := n.AssignedHeading(); !ok || assigned != 150 { + t.Fatalf("expected assigned heading 150, got ok=%v heading=%v", ok, assigned) + } +} + +func TestConditionalHeadingRender(t *testing.T) { + cases := []struct { + action ConditionalHeading + want string // substring in written form + }{ + {ConditionalHeading{Heading: 10, Turn: av.TurnClosest}, "010"}, + {ConditionalHeading{Heading: 100, Turn: av.TurnRight}, "right"}, + {ConditionalHeading{Heading: 100, Turn: av.TurnLeft}, "left"}, + {ConditionalHeading{ByDegrees: 20, Turn: av.TurnLeft}, "left 20"}, + } + r := vrand.Make() + for _, tc := range cases { + rt := &av.RadioTransmission{} + tc.action.Render(rt, r) + written := rt.Written(r) + if !strings.Contains(strings.ToLower(written), strings.ToLower(tc.want)) { + t.Errorf("Render(%+v) = %q; want containing %q", tc.action, written, tc.want) + } + } +} + +func makeTestNav(t *testing.T, heading vmath.MagneticHeading) Nav { + t.Helper() + n := Nav{ + Rand: vrand.Make(), + } + n.FlightState.Heading = heading + n.FlightState.Altitude = 2000 + return n +} From 55ac8c924578f7595561e6f25ea9b72a629ac7ce Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:25:07 -0400 Subject: [PATCH 05/26] nav: test ConditionalHeading right-turn variants Co-Authored-By: Claude Opus 4.7 --- nav/conditional_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/nav/conditional_test.go b/nav/conditional_test.go index 169976e88..184ef442c 100644 --- a/nav/conditional_test.go +++ b/nav/conditional_test.go @@ -45,6 +45,16 @@ func TestConditionalHeadingExecuteByDegreesLeft(t *testing.T) { } } +func TestConditionalHeadingExecuteByDegreesRight(t *testing.T) { + n := makeTestNav(t, 180) + action := ConditionalHeading{ByDegrees: 30, Turn: av.TurnRight} + action.Execute(&n, Time{}) + // TurnRight 30 from 180 -> 210 + if assigned, ok := n.AssignedHeading(); !ok || assigned != 210 { + t.Fatalf("expected assigned heading 210, got ok=%v heading=%v", ok, assigned) + } +} + func TestConditionalHeadingRender(t *testing.T) { cases := []struct { action ConditionalHeading @@ -54,6 +64,7 @@ func TestConditionalHeadingRender(t *testing.T) { {ConditionalHeading{Heading: 100, Turn: av.TurnRight}, "right"}, {ConditionalHeading{Heading: 100, Turn: av.TurnLeft}, "left"}, {ConditionalHeading{ByDegrees: 20, Turn: av.TurnLeft}, "left 20"}, + {ConditionalHeading{ByDegrees: 20, Turn: av.TurnRight}, "right 20"}, } r := vrand.Make() for _, tc := range cases { From 590b80dc5f4b59e08865791c857605052be33354 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:27:06 -0400 Subject: [PATCH 06/26] nav: add ConditionalDirectFix action Implements ConditionalDirectFix with Execute (calls public DirectFix, discarding the intent since conditional firing is silent) and Render (emits direct/left-direct/right-direct fragments). Adds Execute and Render tests using a route-bearing Nav helper. Co-Authored-By: Claude Opus 4.7 --- nav/conditional.go | 23 +++++++++++++++++++++ nav/conditional_test.go | 45 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/nav/conditional.go b/nav/conditional.go index 528037212..9fcd5e4df 100644 --- a/nav/conditional.go +++ b/nav/conditional.go @@ -93,3 +93,26 @@ func (c ConditionalHeading) Render(rt *av.RadioTransmission, r *rand.Rand) { rt.Add("[fly heading|heading] {hdg}", c.Heading) } } + +// ConditionalDirectFix is a deferred direct-to-fix instruction. +type ConditionalDirectFix struct { + Fix string + Turn av.TurnDirection // TurnClosest, TurnLeft, TurnRight +} + +func (c ConditionalDirectFix) Execute(nav *Nav, simTime Time) { + // Silent fire path — discard the intent because conditional actions + // don't produce a readback when they fire. + _ = nav.DirectFix(c.Fix, c.Turn, simTime, 0) +} + +func (c ConditionalDirectFix) Render(rt *av.RadioTransmission, r *rand.Rand) { + switch c.Turn { + case av.TurnLeft: + rt.Add("[left direct|turn left direct] {fix}", c.Fix) + case av.TurnRight: + rt.Add("[right direct|turn right direct] {fix}", c.Fix) + default: + rt.Add("[direct|proceed direct] {fix}", c.Fix) + } +} diff --git a/nav/conditional_test.go b/nav/conditional_test.go index 184ef442c..29397dffb 100644 --- a/nav/conditional_test.go +++ b/nav/conditional_test.go @@ -86,3 +86,48 @@ func makeTestNav(t *testing.T, heading vmath.MagneticHeading) Nav { n.FlightState.Altitude = 2000 return n } + +func TestConditionalDirectFixExecute(t *testing.T) { + n := makeTestNavWithRoute(t, "SAJUL") + action := ConditionalDirectFix{Fix: "SAJUL", Turn: av.TurnClosest} + action.Execute(n, Time{}) + // After direct-fix, the first waypoint should be the target fix. + if len(n.Waypoints) == 0 || n.Waypoints[0].Fix != "SAJUL" { + t.Fatalf("expected first waypoint SAJUL, got %+v", n.Waypoints) + } +} + +func TestConditionalDirectFixRender(t *testing.T) { + cases := []struct { + action ConditionalDirectFix + want string + }{ + {ConditionalDirectFix{Fix: "SAJUL", Turn: av.TurnClosest}, "direct"}, + {ConditionalDirectFix{Fix: "SAJUL", Turn: av.TurnLeft}, "left"}, + {ConditionalDirectFix{Fix: "SAJUL", Turn: av.TurnRight}, "right"}, + } + r := vrand.Make() + for _, tc := range cases { + rt := &av.RadioTransmission{} + tc.action.Render(rt, r) + written := strings.ToLower(rt.Written(r)) + if !strings.Contains(written, strings.ToLower(tc.want)) { + t.Errorf("Render(%+v) = %q; want containing %q", tc.action, written, tc.want) + } + } +} + +// makeTestNavWithRoute returns a *Nav whose Waypoints contains a waypoint +// with the given fix name, suitable for calling DirectFix on it. +func makeTestNavWithRoute(t *testing.T, fix string) *Nav { + t.Helper() + f := NewArrivalFlight(t, ArrivalConfig{ + Waypoints: fix + "/star DETGY/star HAUPT/star", + DepartureAirport: "KMCO", + ArrivalAirport: "KJFK", + AircraftType: "A320", + InitialAltitude: 11000, + InitialSpeed: 250, + }) + return f.nav +} From cbbf54f8b76e5fd8cef5f6ad36e33b8010ffcacc Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:31:16 -0400 Subject: [PATCH 07/26] nav: add ConditionalSpeed and ConditionalMach; thread temperature through Execute Co-Authored-By: Claude Opus 4.7 --- nav/conditional.go | 37 +++++++++++++++++++++++++++--- nav/conditional_test.go | 50 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/nav/conditional.go b/nav/conditional.go index 9fcd5e4df..4ac8b99de 100644 --- a/nav/conditional.go +++ b/nav/conditional.go @@ -30,7 +30,9 @@ const ( type ConditionalAction interface { // Execute mutates nav to carry out the deferred action. Called with the // PendingConditionalCommand slot already cleared, so re-entry is safe. - Execute(nav *Nav, simTime Time) + // temp is the outside air temperature at the aircraft's current altitude, + // required by mach-speed conversions; other actions ignore it. + Execute(nav *Nav, simTime Time, temp av.Temperature) // Render emits the action-specific readback fragment (e.g., "fly heading // 010") used inside ConditionalCommandIntent. @@ -57,7 +59,7 @@ type ConditionalHeading struct { ByDegrees int // nonzero for LnnD / RnnD } -func (c ConditionalHeading) Execute(nav *Nav, simTime Time) { +func (c ConditionalHeading) Execute(nav *Nav, simTime Time, temp av.Temperature) { if c.ByDegrees != 0 { switch c.Turn { case av.TurnLeft: @@ -100,7 +102,7 @@ type ConditionalDirectFix struct { Turn av.TurnDirection // TurnClosest, TurnLeft, TurnRight } -func (c ConditionalDirectFix) Execute(nav *Nav, simTime Time) { +func (c ConditionalDirectFix) Execute(nav *Nav, simTime Time, temp av.Temperature) { // Silent fire path — discard the intent because conditional actions // don't produce a readback when they fire. _ = nav.DirectFix(c.Fix, c.Turn, simTime, 0) @@ -116,3 +118,32 @@ func (c ConditionalDirectFix) Render(rt *av.RadioTransmission, r *rand.Rand) { rt.Add("[direct|proceed direct] {fix}", c.Fix) } } + +// ConditionalSpeed is a deferred speed assignment. +type ConditionalSpeed struct { + Restriction av.SpeedRestriction +} + +func (c ConditionalSpeed) Execute(nav *Nav, simTime Time, temp av.Temperature) { + sr := c.Restriction + _ = nav.AssignSpeed(&sr, false) +} + +func (c ConditionalSpeed) Render(rt *av.RadioTransmission, r *rand.Rand) { + if spd, ok := c.Restriction.ExactValue(); ok { + rt.Add("[reduce speed to|maintain|slowing to] {spd}", int(spd)) + } +} + +// ConditionalMach is a deferred mach-speed assignment. +type ConditionalMach struct { + Mach float32 +} + +func (c ConditionalMach) Execute(nav *Nav, simTime Time, temp av.Temperature) { + _ = nav.AssignMach(c.Mach, false, temp) +} + +func (c ConditionalMach) Render(rt *av.RadioTransmission, r *rand.Rand) { + rt.Add("[mach|maintain mach] {mach}", c.Mach) +} diff --git a/nav/conditional_test.go b/nav/conditional_test.go index 29397dffb..5adb2aa2d 100644 --- a/nav/conditional_test.go +++ b/nav/conditional_test.go @@ -29,7 +29,7 @@ func TestNavHasPendingConditionalCommandField(t *testing.T) { func TestConditionalHeadingExecuteClosest(t *testing.T) { n := makeTestNav(t, 180) action := ConditionalHeading{Heading: 10, Turn: av.TurnClosest} - action.Execute(&n, Time{}) + action.Execute(&n, Time{}, av.Temperature{}) if assigned, ok := n.AssignedHeading(); !ok || assigned != 10 { t.Fatalf("expected assigned heading 10, got ok=%v heading=%v", ok, assigned) } @@ -38,7 +38,7 @@ func TestConditionalHeadingExecuteClosest(t *testing.T) { func TestConditionalHeadingExecuteByDegreesLeft(t *testing.T) { n := makeTestNav(t, 180) action := ConditionalHeading{ByDegrees: 30, Turn: av.TurnLeft} - action.Execute(&n, Time{}) + action.Execute(&n, Time{}, av.Temperature{}) // TurnLeft 30 from 180 -> 150 if assigned, ok := n.AssignedHeading(); !ok || assigned != 150 { t.Fatalf("expected assigned heading 150, got ok=%v heading=%v", ok, assigned) @@ -48,7 +48,7 @@ func TestConditionalHeadingExecuteByDegreesLeft(t *testing.T) { func TestConditionalHeadingExecuteByDegreesRight(t *testing.T) { n := makeTestNav(t, 180) action := ConditionalHeading{ByDegrees: 30, Turn: av.TurnRight} - action.Execute(&n, Time{}) + action.Execute(&n, Time{}, av.Temperature{}) // TurnRight 30 from 180 -> 210 if assigned, ok := n.AssignedHeading(); !ok || assigned != 210 { t.Fatalf("expected assigned heading 210, got ok=%v heading=%v", ok, assigned) @@ -90,7 +90,7 @@ func makeTestNav(t *testing.T, heading vmath.MagneticHeading) Nav { func TestConditionalDirectFixExecute(t *testing.T) { n := makeTestNavWithRoute(t, "SAJUL") action := ConditionalDirectFix{Fix: "SAJUL", Turn: av.TurnClosest} - action.Execute(n, Time{}) + action.Execute(n, Time{}, av.Temperature{}) // After direct-fix, the first waypoint should be the target fix. if len(n.Waypoints) == 0 || n.Waypoints[0].Fix != "SAJUL" { t.Fatalf("expected first waypoint SAJUL, got %+v", n.Waypoints) @@ -131,3 +131,45 @@ func makeTestNavWithRoute(t *testing.T, fix string) *Nav { }) return f.nav } + +func TestConditionalSpeedExecute(t *testing.T) { + f := NewArrivalFlight(t, ArrivalConfig{ + Waypoints: "SAJUL/star DETGY/star HAUPT/star", + DepartureAirport: "KMCO", + ArrivalAirport: "KJFK", + AircraftType: "A320", + InitialAltitude: 11000, + InitialSpeed: 250, + }) + sr := av.MakeAtSpeedRestriction(210) + action := ConditionalSpeed{Restriction: sr} + action.Execute(f.nav, Time{}, av.Temperature{}) + if f.nav.Speed.Assigned == nil { + t.Fatalf("expected Speed.Assigned set, got nil") + } + if got, ok := f.nav.Speed.Assigned.ExactValue(); !ok || got != 210 { + t.Fatalf("expected 210, got ok=%v value=%v", ok, got) + } +} + +func TestConditionalMachExecute(t *testing.T) { + f := NewArrivalFlight(t, ArrivalConfig{ + Waypoints: "SAJUL/star DETGY/star HAUPT/star", + DepartureAirport: "KMCO", + ArrivalAirport: "KJFK", + AircraftType: "A320", + InitialAltitude: 30000, + InitialSpeed: 280, + }) + action := ConditionalMach{Mach: 0.78} + // Use a plausible high-altitude temperature (ISA at 30k ≈ -45°C). + action.Execute(f.nav, Time{}, av.MakeTemperatureFromCelsius(-45)) + + // AssignMach sets Speed.Assigned with IsMach=true. Assert on that surface. + if f.nav.Speed.Assigned == nil { + t.Fatalf("expected Speed.Assigned set, got nil") + } + if !f.nav.Speed.Assigned.IsMach { + t.Fatalf("expected mach restriction, got speed") + } +} From 360b0be7ba31449762e06d5ac8507b1b28ba4833 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:33:27 -0400 Subject: [PATCH 08/26] nav: add ConditionalTriggered predicate Implements the exported trigger predicate that checks whether a pending LV/RC conditional command should fire based on the aircraft's current altitude and vertical rate. Co-Authored-By: Claude Opus 4.7 --- nav/conditional.go | 27 +++++++++++++++++++++++++++ nav/conditional_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/nav/conditional.go b/nav/conditional.go index 4ac8b99de..61ff33cdb 100644 --- a/nav/conditional.go +++ b/nav/conditional.go @@ -147,3 +147,30 @@ func (c ConditionalMach) Execute(nav *Nav, simTime Time, temp av.Temperature) { func (c ConditionalMach) Render(rt *av.RadioTransmission, r *rand.Rand) { rt.Add("[mach|maintain mach] {mach}", c.Mach) } + +// ConditionalTriggered reports whether the pending conditional command +// should fire given the aircraft's current vertical state. +// +// ConditionalLeaving: fires when altitude is >50 ft past trigger in the +// direction of current vertical motion. +// ConditionalReaching: fires when altitude is within 100 ft of trigger. +func ConditionalTriggered(nav *Nav, pc *PendingConditionalCommand) bool { + alt := nav.FlightState.Altitude + diff := alt - pc.Altitude + switch pc.Kind { + case ConditionalLeaving: + const leavingTol = 50.0 + if vmath.Abs(diff) <= leavingTol { + return false + } + rate := nav.FlightState.AltitudeRate + // Same-sign check: diff>0 (above trigger) requires rate>0 (climbing), + // diff<0 (below) requires rate<0 (descending). Zero rate with altitude + // drift outside tolerance (unusual but possible) is not a trigger. + return (diff > 0 && rate > 0) || (diff < 0 && rate < 0) + case ConditionalReaching: + const reachingTol = 100.0 + return vmath.Abs(diff) <= reachingTol + } + return false +} diff --git a/nav/conditional_test.go b/nav/conditional_test.go index 5adb2aa2d..ab236ee7a 100644 --- a/nav/conditional_test.go +++ b/nav/conditional_test.go @@ -173,3 +173,41 @@ func TestConditionalMachExecute(t *testing.T) { t.Fatalf("expected mach restriction, got speed") } } + +func TestConditionalTriggered(t *testing.T) { + cases := []struct { + name string + kind ConditionalKind + trigger float32 + altitude float32 + rate float32 // vertical rate (positive = climb) + want bool + }{ + // --- ConditionalLeaving --- + {"LV climbing well past", ConditionalLeaving, 3000, 3200, +500, true}, + {"LV descending well past", ConditionalLeaving, 3000, 2800, -500, true}, + {"LV level at trigger", ConditionalLeaving, 3000, 3000, 0, false}, + {"LV within tolerance climbing", ConditionalLeaving, 3000, 3020, +500, false}, // <50ft past + {"LV 60ft past climbing", ConditionalLeaving, 3000, 3060, +500, true}, + {"LV 60ft below climbing (wrong dir)", ConditionalLeaving, 3000, 2940, +500, false}, + // --- ConditionalReaching --- + {"RC within 100ft", ConditionalReaching, 10000, 9950, +500, true}, + {"RC 50ft past still climbing", ConditionalReaching, 10000, 10050, +500, true}, + {"RC 200ft short climbing", ConditionalReaching, 10000, 9800, +500, false}, + {"RC leveled at target", ConditionalReaching, 10000, 10000, 0, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Build a minimal Nav — no need for full FlightTest here; only + // FlightState.{Altitude, AltitudeRate} are read. + var n Nav + n.FlightState.Altitude = tc.altitude + n.FlightState.AltitudeRate = tc.rate + pc := &PendingConditionalCommand{Kind: tc.kind, Altitude: tc.trigger} + if got := ConditionalTriggered(&n, pc); got != tc.want { + t.Errorf("want %v got %v (kind=%v trigger=%v alt=%v rate=%v)", + tc.want, got, tc.kind, tc.trigger, tc.altitude, tc.rate) + } + }) + } +} From 4f92ba71b8b3bf6fc1564ba7944eb10ffd53d82e Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:35:34 -0400 Subject: [PATCH 09/26] sim: add triggerReachable helper for LV/RC conditional commands Co-Authored-By: Claude Opus 4.7 --- sim/control.go | 43 +++++++++++++++++++++++++++++++++++++++++++ sim/control_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/sim/control.go b/sim/control.go index 379279dc0..3691ac284 100644 --- a/sim/control.go +++ b/sim/control.go @@ -3479,6 +3479,49 @@ func parseSpeedUntil(untilStr string) *av.SpeedUntil { return &av.SpeedUntil{Fix: untilStr} } +// triggerReachable reports whether a LV/RC trigger altitude is +// reasonably reachable from the aircraft's current vertical state, +// allowing the controller command to be accepted. +// +// For ConditionalLeaving: accepted if the aircraft is within 500 ft of +// the trigger (so "leaving 3,000" works even for an aircraft at 3,050), +// or if the trigger lies between current altitude and assigned target. +// +// For ConditionalReaching: accepted if the trigger lies between current +// altitude and assigned target, or (if no target assigned) the aircraft +// is within 500 ft of the trigger. +func triggerReachable(ac *Aircraft, kind nav.ConditionalKind, trigger float32) bool { + cur := ac.Nav.FlightState.Altitude + target := ac.Nav.Altitude.Assigned + diff := math.Abs(cur - trigger) + switch kind { + case nav.ConditionalLeaving: + if diff <= 500 { + return true + } + if target == nil { + return false + } + return betweenAlt(trigger, cur, *target) + case nav.ConditionalReaching: + if target == nil { + return diff <= 500 + } + return betweenAlt(trigger, cur, *target) + } + return false +} + +// betweenAlt reports whether v lies between a and b (inclusive), in +// either ordering. +func betweenAlt(v, a, b float32) bool { + lo, hi := a, b + if lo > hi { + lo, hi = hi, lo + } + return v >= lo && v <= hi +} + // parseCompoundSpeed parses a compound speed command string like // "250+/UFIX1/210-/UFIX2/180+" into CompoundSpeedSegments. // The input is the part after 'S' (e.g., "250+/UFIX1/210-/UFIX2/180+"). diff --git a/sim/control_test.go b/sim/control_test.go index 7c9a8171e..0ea282f40 100644 --- a/sim/control_test.go +++ b/sim/control_test.go @@ -350,3 +350,35 @@ func TestRunOneControlCommandAtFixClearedStraightInApproach(t *testing.T) { t.Fatal("AtFixClearedRoute was not populated") } } + +func TestTriggerReachable(t *testing.T) { + cases := []struct { + name string + kind nav.ConditionalKind + trigger float32 + current float32 + assigned *float32 + want bool + }{ + // LV: within 500ft slack even if direction is wrong + {"LV aircraft at 3050 climbing past", nav.ConditionalLeaving, 3000, 3050, ptr[float32](5000), true}, + {"LV aircraft far past", nav.ConditionalLeaving, 3000, 5000, ptr[float32](7000), false}, + {"LV trigger in path", nav.ConditionalLeaving, 3000, 1000, ptr[float32](5000), true}, + {"LV no target, far from trigger", nav.ConditionalLeaving, 3000, 8000, nil, false}, + // RC: trigger must be between current and assigned target + {"RC target is trigger", nav.ConditionalReaching, 10000, 5000, ptr[float32](10000), true}, + {"RC trigger above target", nav.ConditionalReaching, 12000, 5000, ptr[float32](10000), false}, + {"RC no target but close", nav.ConditionalReaching, 10000, 9900, nil, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ac := &Aircraft{} + ac.Nav.FlightState.Altitude = tc.current + ac.Nav.Altitude.Assigned = tc.assigned + got := triggerReachable(ac, tc.kind, tc.trigger) + if got != tc.want { + t.Errorf("want %v got %v", tc.want, got) + } + }) + } +} From bb0b466efc8d5ec153b664182e9a0765e6fdcd9d Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:36:52 -0400 Subject: [PATCH 10/26] sim: add parseConditionalAltitude helper Co-Authored-By: Claude Opus 4.7 --- sim/control.go | 17 +++++++++++++++++ sim/control_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/sim/control.go b/sim/control.go index 3691ac284..dd7303049 100644 --- a/sim/control.go +++ b/sim/control.go @@ -3522,6 +3522,23 @@ func betweenAlt(v, a, b float32) bool { return v >= lo && v <= hi } +// parseConditionalAltitude parses the altitude-encoding convention used +// by LV/RC commands: number × 100, with a carve-out for values that look +// like feet already (>600 and evenly divisible by 100). +func parseConditionalAltitude(s string) (float32, error) { + if s == "" { + return 0, ErrInvalidCommandSyntax + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, err + } + if n > 600 && n%100 == 0 { + return float32(n), nil + } + return float32(n * 100), nil +} + // parseCompoundSpeed parses a compound speed command string like // "250+/UFIX1/210-/UFIX2/180+" into CompoundSpeedSegments. // The input is the part after 'S' (e.g., "250+/UFIX1/210-/UFIX2/180+"). diff --git a/sim/control_test.go b/sim/control_test.go index 0ea282f40..23f6bf61d 100644 --- a/sim/control_test.go +++ b/sim/control_test.go @@ -382,3 +382,29 @@ func TestTriggerReachable(t *testing.T) { }) } } + +func TestParseConditionalAltitude(t *testing.T) { + cases := []struct { + in string + want float32 + wantErr bool + }{ + {"30", 3000, false}, // hundreds-of-feet + {"130", 13000, false}, + {"100", 10000, false}, + {"1000", 1000, false}, // >600 && %100==0 → already feet + {"13000", 13000, false}, // ditto + {"", 0, true}, + {"abc", 0, true}, + } + for _, tc := range cases { + got, err := parseConditionalAltitude(tc.in) + if (err != nil) != tc.wantErr { + t.Errorf("parseConditionalAltitude(%q) err=%v wantErr=%v", tc.in, err, tc.wantErr) + continue + } + if !tc.wantErr && got != tc.want { + t.Errorf("parseConditionalAltitude(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} From 5724bba3e16961102c1b8384688cc265f7700345 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:38:46 -0400 Subject: [PATCH 11/26] sim: add parseConditionalAction for LV/RC inner commands Co-Authored-By: Claude Opus 4.7 --- sim/control.go | 76 +++++++++++++++++++++++++++++++++++++++++++++ sim/control_test.go | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/sim/control.go b/sim/control.go index dd7303049..510f2d097 100644 --- a/sim/control.go +++ b/sim/control.go @@ -3539,6 +3539,82 @@ func parseConditionalAltitude(s string) (float32, error) { return float32(n * 100), nil } +// parseConditionalAction parses an inner command string (the right-hand +// side of LV/RC) into a typed ConditionalAction. Accepts only lateral and +// speed/mach actions; altitude-changing and unknown inners return +// ErrInvalidCommandSyntax. +// +// Grammar: +// +// H{hdg} → ConditionalHeading (closest turn) +// L{hdg} | R{hdg} → ConditionalHeading (left/right turn to heading) +// L{deg}D | R{deg}D → ConditionalHeading (turn N degrees) +// D{fix} → ConditionalDirectFix (closest) +// LD{fix} | RD{fix} → ConditionalDirectFix (left/right) +// S{spd} → ConditionalSpeed +// M{mach} → ConditionalMach (2-digit mach, e.g. M78 → 0.78) +func parseConditionalAction(s string) (nav.ConditionalAction, error) { + if len(s) < 2 { + return nil, ErrInvalidCommandSyntax + } + switch s[0] { + case 'H': + hdg, err := strconv.Atoi(s[1:]) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalHeading{Heading: hdg, Turn: av.TurnClosest}, nil + + case 'L', 'R': + turn := av.TurnLeft + if s[0] == 'R' { + turn = av.TurnRight + } + // LD{fix} / RD{fix} + if len(s) >= 5 && s[1] == 'D' { + return nav.ConditionalDirectFix{Fix: strings.ToUpper(s[2:]), Turn: turn}, nil + } + // LnnD / RnnD + if l := len(s); l > 2 && s[l-1] == 'D' { + deg, err := strconv.Atoi(s[1 : l-1]) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalHeading{ByDegrees: deg, Turn: turn}, nil + } + // L{hdg} / R{hdg} + hdg, err := strconv.Atoi(s[1:]) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalHeading{Heading: hdg, Turn: turn}, nil + + case 'D': + if len(s) < 4 { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalDirectFix{Fix: strings.ToUpper(s[1:]), Turn: av.TurnClosest}, nil + + case 'S': + sr, err := av.ParseSpeedRestriction(s[1:]) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalSpeed{Restriction: *sr}, nil + + case 'M': + if len(s) != 3 { + return nil, ErrInvalidCommandSyntax + } + mach, err := strconv.ParseFloat(s[1:], 32) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalMach{Mach: float32(mach) / 100.0}, nil + } + return nil, ErrInvalidCommandSyntax +} + // parseCompoundSpeed parses a compound speed command string like // "250+/UFIX1/210-/UFIX2/180+" into CompoundSpeedSegments. // The input is the part after 'S' (e.g., "250+/UFIX1/210-/UFIX2/180+"). diff --git a/sim/control_test.go b/sim/control_test.go index 23f6bf61d..d88a95c59 100644 --- a/sim/control_test.go +++ b/sim/control_test.go @@ -4,6 +4,7 @@ package sim import ( + "reflect" "testing" av "github.com/mmp/vice/aviation" @@ -408,3 +409,58 @@ func TestParseConditionalAltitude(t *testing.T) { } } } + +func TestParseConditionalAction(t *testing.T) { + cases := []struct { + in string + wantType string // type name of returned ConditionalAction + wantProps map[string]any + wantErr bool + }{ + {"H010", "ConditionalHeading", map[string]any{"Heading": 10, "Turn": av.TurnClosest}, false}, + {"L100", "ConditionalHeading", map[string]any{"Heading": 100, "Turn": av.TurnLeft}, false}, + {"R100", "ConditionalHeading", map[string]any{"Heading": 100, "Turn": av.TurnRight}, false}, + {"L20D", "ConditionalHeading", map[string]any{"ByDegrees": 20, "Turn": av.TurnLeft}, false}, + {"R30D", "ConditionalHeading", map[string]any{"ByDegrees": 30, "Turn": av.TurnRight}, false}, + {"DAAC", "ConditionalDirectFix", map[string]any{"Fix": "AAC", "Turn": av.TurnClosest}, false}, + {"LDAAC", "ConditionalDirectFix", map[string]any{"Fix": "AAC", "Turn": av.TurnLeft}, false}, + {"RDAAC", "ConditionalDirectFix", map[string]any{"Fix": "AAC", "Turn": av.TurnRight}, false}, + {"S210", "ConditionalSpeed", nil, false}, + {"M78", "ConditionalMach", map[string]any{"Mach": float32(0.78)}, false}, + + // Rejections: altitude-changing inners, unknowns, malformed + {"C50", "", nil, true}, + {"CVS", "", nil, true}, + {"DVS", "", nil, true}, + {"X010", "", nil, true}, + {"", "", nil, true}, + {"H", "", nil, true}, + {"HXYZ", "", nil, true}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + got, err := parseConditionalAction(tc.in) + if (err != nil) != tc.wantErr { + t.Fatalf("parseConditionalAction(%q) err=%v wantErr=%v", tc.in, err, tc.wantErr) + } + if tc.wantErr { + return + } + typeName := reflect.TypeOf(got).Name() + if typeName != tc.wantType { + t.Fatalf("parseConditionalAction(%q) type = %s, want %s", tc.in, typeName, tc.wantType) + } + v := reflect.ValueOf(got) + for k, want := range tc.wantProps { + field := v.FieldByName(k) + if !field.IsValid() { + t.Errorf("no field %s on %s", k, typeName) + continue + } + if !reflect.DeepEqual(field.Interface(), want) { + t.Errorf("%s.%s = %v, want %v", typeName, k, field.Interface(), want) + } + } + }) + } +} From 5d1b0d243a8ee49c0c82e71a489c4061a615ff52 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:44:12 -0400 Subject: [PATCH 12/26] aviation: add ConditionalCommandIntent; nav: alias enum to aviation --- aviation/intent.go | 40 ++++++++++++++++++++++++++++++++++++++++ aviation/intent_test.go | 34 ++++++++++++++++++++++++++++++++++ nav/conditional.go | 22 ++++++++++++---------- 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/aviation/intent.go b/aviation/intent.go index 89b7bba59..57b408d62 100644 --- a/aviation/intent.go +++ b/aviation/intent.go @@ -882,6 +882,46 @@ func (a ATISIntent) Render(rt *RadioTransmission, r *rand.Rand) { rt.Add("[we'll pick up {ch}|we'll get {ch}]", a.Letter) } +// ConditionalKind identifies which altitude event fires a deferred +// controller command (LV = leaving, RC = reaching). Declared in aviation +// because ConditionalCommandIntent needs it; nav aliases this type. +type ConditionalKind uint8 + +const ( + ConditionalLeaving ConditionalKind = iota + ConditionalReaching +) + +// ConditionalActionRender is the readback-only subset of +// nav.ConditionalAction. Declared here to avoid an import cycle (nav +// imports aviation, not the other way around); nav's ConditionalAction +// interface embeds Render with a compatible signature, so concrete nav +// actions satisfy this interface. +type ConditionalActionRender interface { + Render(rt *RadioTransmission, r *rand.Rand) +} + +// ConditionalCommandIntent is the readback for a "leaving/reaching {alt}, +// do X" command. It composes with the inner action's own Render so +// phraseology stays consistent with non-conditional variants. +type ConditionalCommandIntent struct { + Kind ConditionalKind + Altitude float32 + Action ConditionalActionRender +} + +func (c ConditionalCommandIntent) Render(rt *RadioTransmission, r *rand.Rand) { + switch c.Kind { + case ConditionalLeaving: + rt.Add("[leaving|passing] {alt}, ", c.Altitude) + case ConditionalReaching: + rt.Add("[reaching|level at|on reaching] {alt}, ", c.Altitude) + } + if c.Action != nil { + c.Action.Render(rt, r) + } +} + /////////////////////////////////////////////////////////////////////////// // Traffic Advisory Intent diff --git a/aviation/intent_test.go b/aviation/intent_test.go index 310769c1d..2ef16c24e 100644 --- a/aviation/intent_test.go +++ b/aviation/intent_test.go @@ -168,6 +168,40 @@ func TestContactTowerReadback(t *testing.T) { } } +type stubConditionalAction struct{ text string } + +func (s stubConditionalAction) Render(rt *RadioTransmission, r *rand.Rand) { + rt.Add(s.text) +} + +func TestConditionalCommandIntentRender(t *testing.T) { + t.Run("leaving", func(t *testing.T) { + intent := ConditionalCommandIntent{ + Kind: ConditionalLeaving, + Altitude: 3000, + Action: stubConditionalAction{text: "fly heading 010"}, + } + readback := renderIntentForTest(intent, 1) + assertContainsAny(t, readback, "leaving", "passing") + if !strings.Contains(readback, "fly heading 010") { + t.Fatalf("leaving readback missing action text: %q", readback) + } + }) + + t.Run("reaching", func(t *testing.T) { + intent := ConditionalCommandIntent{ + Kind: ConditionalReaching, + Altitude: 10000, + Action: stubConditionalAction{text: "fly heading 010"}, + } + readback := renderIntentForTest(intent, 1) + assertContainsAny(t, readback, "reaching", "level at", "on reaching") + if !strings.Contains(readback, "fly heading 010") { + t.Fatalf("reaching readback missing action text: %q", readback) + } + }) +} + func TestCompoundSpeedReadbackIncludesQualifiers(t *testing.T) { above := MakeAtOrAboveSpeedRestriction(250) below := MakeAtOrBelowSpeedRestriction(210) diff --git a/nav/conditional.go b/nav/conditional.go index 61ff33cdb..6e9e678c2 100644 --- a/nav/conditional.go +++ b/nav/conditional.go @@ -10,18 +10,20 @@ import ( "github.com/mmp/vice/rand" ) -// ConditionalKind identifies which altitude-event triggers the deferred action. -type ConditionalKind uint8 +// ConditionalKind is an alias for av.ConditionalKind so that nav and +// aviation share the same enum (aviation holds the canonical definition +// because ConditionalCommandIntent lives there and nav cannot be imported +// from aviation). +// +// ConditionalLeaving fires once the aircraft's altitude has passed the +// trigger by more than a small tolerance in the direction of current +// vertical motion. ConditionalReaching fires on first contact within +// 100 ft of the trigger altitude, regardless of vertical rate. +type ConditionalKind = av.ConditionalKind const ( - // ConditionalLeaving fires once the aircraft's altitude has passed the - // trigger by more than a small tolerance in the direction of current - // vertical motion. - ConditionalLeaving ConditionalKind = iota - - // ConditionalReaching fires on first contact within 100 ft of the trigger - // altitude, regardless of vertical rate. - ConditionalReaching + ConditionalLeaving = av.ConditionalLeaving + ConditionalReaching = av.ConditionalReaching ) // ConditionalAction is the deferred action to execute when a LV/RC trigger From 94e6f366f82eca0dc1eeee559ea2f31ba26dab42 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:47:44 -0400 Subject: [PATCH 13/26] aviation: loop seeds in TestConditionalCommandIntentRender --- aviation/intent_test.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/aviation/intent_test.go b/aviation/intent_test.go index 2ef16c24e..7acf98f64 100644 --- a/aviation/intent_test.go +++ b/aviation/intent_test.go @@ -181,10 +181,12 @@ func TestConditionalCommandIntentRender(t *testing.T) { Altitude: 3000, Action: stubConditionalAction{text: "fly heading 010"}, } - readback := renderIntentForTest(intent, 1) - assertContainsAny(t, readback, "leaving", "passing") - if !strings.Contains(readback, "fly heading 010") { - t.Fatalf("leaving readback missing action text: %q", readback) + for seed := uint64(1); seed <= 20; seed++ { + readback := renderIntentForTest(intent, seed) + assertContainsAny(t, readback, "leaving", "passing") + if !strings.Contains(readback, "fly heading 010") { + t.Fatalf("leaving readback missing action text: %q", readback) + } } }) @@ -194,10 +196,12 @@ func TestConditionalCommandIntentRender(t *testing.T) { Altitude: 10000, Action: stubConditionalAction{text: "fly heading 010"}, } - readback := renderIntentForTest(intent, 1) - assertContainsAny(t, readback, "reaching", "level at", "on reaching") - if !strings.Contains(readback, "fly heading 010") { - t.Fatalf("reaching readback missing action text: %q", readback) + for seed := uint64(1); seed <= 20; seed++ { + readback := renderIntentForTest(intent, seed) + assertContainsAny(t, readback, "reaching", "level at", "on reaching") + if !strings.Contains(readback, "fly heading 010") { + t.Fatalf("reaching readback missing action text: %q", readback) + } } }) } From 0305ce268c9ef82c970b6dc23e785a0309e9cff1 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:51:40 -0400 Subject: [PATCH 14/26] sim: add AssignConditional method for LV/RC commands --- sim/control.go | 26 +++++++++++++ sim/control_test.go | 91 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/sim/control.go b/sim/control.go index 510f2d097..b4b4d0a38 100644 --- a/sim/control.go +++ b/sim/control.go @@ -3512,6 +3512,32 @@ func triggerReachable(ac *Aircraft, kind nav.ConditionalKind, trigger float32) b return false } +// AssignConditional installs a deferred LV/RC action on the aircraft's +// nav state. Fires silently when sim.updateState observes the altitude +// trigger. Returns an UnableIntent if the trigger is not reachable from +// the aircraft's current vertical state; the outer error is reserved for +// lookup/authorization failures. +func (s *Sim) AssignConditional(tcw TCW, callsign av.ADSBCallsign, + kind nav.ConditionalKind, altitude float32, action nav.ConditionalAction) (av.CommandIntent, error) { + return s.dispatchControlledAircraftCommand(tcw, callsign, + func(tcw TCW, ac *Aircraft) av.CommandIntent { + if !triggerReachable(ac, kind, altitude) { + return av.MakeUnableIntent("unable. %s is out of our climb/descent path.", + av.FormatAltitude(altitude)) + } + ac.Nav.PendingConditionalCommand = &nav.PendingConditionalCommand{ + Kind: kind, + Altitude: altitude, + Action: action, + } + return av.ConditionalCommandIntent{ + Kind: kind, + Altitude: altitude, + Action: action, + } + }) +} + // betweenAlt reports whether v lies between a and b (inclusive), in // either ordering. func betweenAlt(v, a, b float32) bool { diff --git a/sim/control_test.go b/sim/control_test.go index d88a95c59..88d77db84 100644 --- a/sim/control_test.go +++ b/sim/control_test.go @@ -410,6 +410,97 @@ func TestParseConditionalAltitude(t *testing.T) { } } +func setupTestSimWithAircraftAt(t *testing.T, altitude, assigned float32) (*Sim, av.ADSBCallsign, TCW) { + t.Helper() + lg := log.New(true, "error", t.TempDir()) + callsign := av.ADSBCallsign("TEST123") + tcw := TCW("TCW1") + s := &Sim{ + State: &CommonState{ + DynamicState: DynamicState{ + CurrentConsolidation: map[TCW]*TCPConsolidation{ + tcw: {PrimaryTCP: "1A"}, + }, + }, + }, + Aircraft: map[av.ADSBCallsign]*Aircraft{ + callsign: { + ADSBCallsign: callsign, + ControllerFrequency: "1A", + Nav: nav.Nav{ + FlightState: nav.FlightState{ + Altitude: altitude, + }, + Altitude: nav.NavAltitude{ + Assigned: ptr[float32](assigned), + }, + }, + }, + }, + PendingContacts: map[TCP][]PendingContact{}, + PrivilegedTCWs: map[TCW]bool{tcw: true}, + lg: lg, + } + return s, callsign, tcw +} + +func TestAssignConditionalInstallsSlot(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) + action := nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest} + intent, err := s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, 3000, action) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if intent == nil { + t.Fatalf("expected non-nil intent") + } + if _, ok := intent.(av.ConditionalCommandIntent); !ok { + t.Fatalf("expected ConditionalCommandIntent, got %T", intent) + } + pc := s.Aircraft[callsign].Nav.PendingConditionalCommand + if pc == nil { + t.Fatalf("expected PendingConditionalCommand installed") + } + if pc.Altitude != 3000 { + t.Fatalf("wrong altitude: %v", pc.Altitude) + } + if pc.Kind != nav.ConditionalLeaving { + t.Fatalf("wrong kind: %v", pc.Kind) + } +} + +func TestAssignConditionalRejectsUnreachable(t *testing.T) { + // Aircraft at 5000 level (assigned also 5000); trigger 3000 -> unreachable. + s, callsign, tcw := setupTestSimWithAircraftAt(t, 5000, 5000) + action := nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest} + intent, err := s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, 3000, action) + if err != nil { + t.Fatalf("unexpected dispatch error: %v", err) + } + if _, ok := intent.(av.UnableIntent); !ok { + t.Fatalf("expected UnableIntent for unreachable trigger, got %T", intent) + } + if s.Aircraft[callsign].Nav.PendingConditionalCommand != nil { + t.Fatalf("expected no slot installed after unable") + } +} + +func TestAssignConditionalSupersedes(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) + first := nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest} + second := nav.ConditionalDirectFix{Fix: "AAC", Turn: av.TurnClosest} + if _, err := s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, 3000, first); err != nil { + t.Fatalf("first assign: %v", err) + } + if _, err := s.AssignConditional(tcw, callsign, nav.ConditionalReaching, 6000, second); err != nil { + t.Fatalf("second assign: %v", err) + } + pc := s.Aircraft[callsign].Nav.PendingConditionalCommand + if pc == nil || pc.Kind != nav.ConditionalReaching || pc.Altitude != 6000 { + t.Fatalf("expected superseded slot: reaching 6000, got %+v", pc) + } +} + func TestParseConditionalAction(t *testing.T) { cases := []struct { in string From c9f9cdfd728d37dd6f08204b7ccfeee706b1b3d9 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:56:02 -0400 Subject: [PATCH 15/26] sim: lock AssignConditional and relocate to Assign* cluster Co-Authored-By: Claude Sonnet 4.6 --- sim/control.go | 54 +++++++++++++++++++++++---------------------- sim/control_test.go | 8 ++++++- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/sim/control.go b/sim/control.go index b4b4d0a38..a9e79f14b 100644 --- a/sim/control.go +++ b/sim/control.go @@ -1435,6 +1435,34 @@ func (s *Sim) AssignCompoundSpeed(tcw TCW, callsign av.ADSBCallsign, segments [] }) } +// AssignConditional installs a deferred LV/RC action on the aircraft's +// nav state. Fires silently when sim.updateState observes the altitude +// trigger. Returns an UnableIntent if the trigger is not reachable from +// the aircraft's current vertical state; the outer error is reserved for +// lookup/authorization failures. +func (s *Sim) AssignConditional(tcw TCW, callsign av.ADSBCallsign, + kind nav.ConditionalKind, altitude float32, action nav.ConditionalAction) (av.CommandIntent, error) { + s.mu.Lock(s.lg) + defer s.mu.Unlock(s.lg) + + return s.dispatchControlledAircraftCommand(tcw, callsign, + func(tcw TCW, ac *Aircraft) av.CommandIntent { + if !triggerReachable(ac, kind, altitude) { + return av.MakeUnableIntent("unable. {alt} is out of our climb/descent path.", altitude) + } + ac.Nav.PendingConditionalCommand = &nav.PendingConditionalCommand{ + Kind: kind, + Altitude: altitude, + Action: action, + } + return av.ConditionalCommandIntent{ + Kind: kind, + Altitude: altitude, + Action: action, + } + }) +} + func (s *Sim) MaintainSlowestPractical(tcw TCW, callsign av.ADSBCallsign) (av.CommandIntent, error) { s.mu.Lock(s.lg) defer s.mu.Unlock(s.lg) @@ -3512,32 +3540,6 @@ func triggerReachable(ac *Aircraft, kind nav.ConditionalKind, trigger float32) b return false } -// AssignConditional installs a deferred LV/RC action on the aircraft's -// nav state. Fires silently when sim.updateState observes the altitude -// trigger. Returns an UnableIntent if the trigger is not reachable from -// the aircraft's current vertical state; the outer error is reserved for -// lookup/authorization failures. -func (s *Sim) AssignConditional(tcw TCW, callsign av.ADSBCallsign, - kind nav.ConditionalKind, altitude float32, action nav.ConditionalAction) (av.CommandIntent, error) { - return s.dispatchControlledAircraftCommand(tcw, callsign, - func(tcw TCW, ac *Aircraft) av.CommandIntent { - if !triggerReachable(ac, kind, altitude) { - return av.MakeUnableIntent("unable. %s is out of our climb/descent path.", - av.FormatAltitude(altitude)) - } - ac.Nav.PendingConditionalCommand = &nav.PendingConditionalCommand{ - Kind: kind, - Altitude: altitude, - Action: action, - } - return av.ConditionalCommandIntent{ - Kind: kind, - Altitude: altitude, - Action: action, - } - }) -} - // betweenAlt reports whether v lies between a and b (inclusive), in // either ordering. func betweenAlt(v, a, b float32) bool { diff --git a/sim/control_test.go b/sim/control_test.go index 88d77db84..73510153f 100644 --- a/sim/control_test.go +++ b/sim/control_test.go @@ -496,7 +496,13 @@ func TestAssignConditionalSupersedes(t *testing.T) { t.Fatalf("second assign: %v", err) } pc := s.Aircraft[callsign].Nav.PendingConditionalCommand - if pc == nil || pc.Kind != nav.ConditionalReaching || pc.Altitude != 6000 { + if pc == nil { + t.Fatalf("expected superseded slot, got nil") + } + if pc.Kind == nav.ConditionalLeaving { + t.Fatalf("old Leaving kind not replaced") + } + if pc.Kind != nav.ConditionalReaching || pc.Altitude != 6000 { t.Fatalf("expected superseded slot: reaching 6000, got %+v", pc) } } From a495ccd7f58d2ed9914e402882c936c94c3f1fcc Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:58:11 -0400 Subject: [PATCH 16/26] sim: dispatch LV{alt}/{inner} as conditional-leaving command --- sim/control.go | 15 +++++++++++++++ sim/control_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/sim/control.go b/sim/control.go index a9e79f14b..62ce2d00e 100644 --- a/sim/control.go +++ b/sim/control.go @@ -4123,6 +4123,21 @@ func (s *Sim) runOneControlCommand(tcw TCW, callsign av.ADSBCallsign, command st } case 'L': + if strings.HasPrefix(command, "LV") && len(command) > 2 { + altStr, inner, ok := strings.Cut(command[2:], "/") + if !ok || altStr == "" || inner == "" { + return nil, ErrInvalidCommandSyntax + } + alt, err := parseConditionalAltitude(altStr) + if err != nil { + return nil, err + } + action, err := parseConditionalAction(inner) + if err != nil { + return nil, err + } + return s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, alt, action) + } if len(command) >= 5 && command[1] == 'D' { return s.DirectFix(tcw, callsign, command[2:], av.TurnLeft, delayReduction) } else if l := len(command); l > 2 && command[l-1] == 'D' { diff --git a/sim/control_test.go b/sim/control_test.go index 73510153f..fe4eca0d9 100644 --- a/sim/control_test.go +++ b/sim/control_test.go @@ -561,3 +561,41 @@ func TestParseConditionalAction(t *testing.T) { }) } } + +func TestRunControlCommandLV(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) + intent, err := s.runOneControlCommand(tcw, callsign, "LV30/H010", 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := intent.(av.ConditionalCommandIntent); !ok { + t.Fatalf("expected ConditionalCommandIntent, got %T", intent) + } + ac := s.Aircraft[callsign] + if ac.Nav.PendingConditionalCommand == nil { + t.Fatalf("slot not installed") + } + if ac.Nav.PendingConditionalCommand.Altitude != 3000 { + t.Fatalf("wrong altitude %v", ac.Nav.PendingConditionalCommand.Altitude) + } +} + +func TestRunControlCommandLVRejectsMalformed(t *testing.T) { + cases := []string{ + "LV30H010", // missing slash + "LV/H010", // empty altitude + "LV30/", // empty inner + "LVABC/H010", // non-numeric altitude + "LV30/C50", // altitude-changing inner (C50 means descent — rejected by parseConditionalAction) + "LV30/X010", // unknown inner + } + for _, cmd := range cases { + t.Run(cmd, func(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) + _, err := s.runOneControlCommand(tcw, callsign, cmd, 0) + if err == nil { + t.Fatalf("expected error for %q, got nil", cmd) + } + }) + } +} From 3959d615af40d243245f8da0cf24b9ebe05c6914 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:01:54 -0400 Subject: [PATCH 17/26] sim: tighten LV malformed-input handling and test assertions Co-Authored-By: Claude Sonnet 4.6 --- sim/control.go | 5 ++++- sim/control_test.go | 36 ++++++++++++++++++++++-------------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/sim/control.go b/sim/control.go index 62ce2d00e..d27995809 100644 --- a/sim/control.go +++ b/sim/control.go @@ -4123,7 +4123,10 @@ func (s *Sim) runOneControlCommand(tcw TCW, callsign av.ADSBCallsign, command st } case 'L': - if strings.HasPrefix(command, "LV") && len(command) > 2 { + if strings.HasPrefix(command, "LV") { + if len(command) <= 2 { + return nil, ErrInvalidCommandSyntax + } altStr, inner, ok := strings.Cut(command[2:], "/") if !ok || altStr == "" || inner == "" { return nil, ErrInvalidCommandSyntax diff --git a/sim/control_test.go b/sim/control_test.go index fe4eca0d9..e108cc4c0 100644 --- a/sim/control_test.go +++ b/sim/control_test.go @@ -4,6 +4,7 @@ package sim import ( + "errors" "reflect" "testing" @@ -562,7 +563,7 @@ func TestParseConditionalAction(t *testing.T) { } } -func TestRunControlCommandLV(t *testing.T) { +func TestRunOneControlCommandLV(t *testing.T) { s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) intent, err := s.runOneControlCommand(tcw, callsign, "LV30/H010", 0) if err != nil { @@ -580,21 +581,28 @@ func TestRunControlCommandLV(t *testing.T) { } } -func TestRunControlCommandLVRejectsMalformed(t *testing.T) { - cases := []string{ - "LV30H010", // missing slash - "LV/H010", // empty altitude - "LV30/", // empty inner - "LVABC/H010", // non-numeric altitude - "LV30/C50", // altitude-changing inner (C50 means descent — rejected by parseConditionalAction) - "LV30/X010", // unknown inner - } - for _, cmd := range cases { - t.Run(cmd, func(t *testing.T) { +func TestRunOneControlCommandLVRejectsMalformed(t *testing.T) { + cases := []struct { + cmd string + wantSyntax bool + }{ + {"LV", true}, // bare command, too short + {"LV30H010", true}, // missing slash + {"LV/H010", true}, // empty altitude + {"LV30/", true}, // empty inner + {"LVABC/H010", false}, // non-numeric altitude (strconv error) + {"LV30/X010", true}, // unknown inner command + {"LV30/C50", true}, // altitude-changing inner rejected by parseConditionalAction + } + for _, tc := range cases { + t.Run(tc.cmd, func(t *testing.T) { s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) - _, err := s.runOneControlCommand(tcw, callsign, cmd, 0) + _, err := s.runOneControlCommand(tcw, callsign, tc.cmd, 0) if err == nil { - t.Fatalf("expected error for %q, got nil", cmd) + t.Fatalf("expected error for %q, got nil", tc.cmd) + } + if tc.wantSyntax && !errors.Is(err, ErrInvalidCommandSyntax) { + t.Fatalf("expected ErrInvalidCommandSyntax for %q, got %v", tc.cmd, err) } }) } From 2073c6c1c6fabb083585c3cb80c650f46f159ff2 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:03:56 -0400 Subject: [PATCH 18/26] sim: dispatch RC{alt}/{inner} as conditional-reaching command --- sim/control.go | 17 ++++++++++++++++ sim/control_test.go | 48 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/sim/control.go b/sim/control.go index d27995809..6c94019bf 100644 --- a/sim/control.go +++ b/sim/control.go @@ -4188,6 +4188,23 @@ func (s *Sim) runOneControlCommand(tcw TCW, callsign av.ADSBCallsign, command st return s.ResumeOwnNavigation(tcw, callsign) } else if command == "RST" { return s.RadarServicesTerminated(tcw, callsign) + } else if strings.HasPrefix(command, "RC") { + if len(command) <= 2 { + return nil, ErrInvalidCommandSyntax + } + altStr, inner, ok := strings.Cut(command[2:], "/") + if !ok || altStr == "" || inner == "" { + return nil, ErrInvalidCommandSyntax + } + alt, err := parseConditionalAltitude(altStr) + if err != nil { + return nil, err + } + action, err := parseConditionalAction(inner) + if err != nil { + return nil, err + } + return s.AssignConditional(tcw, callsign, nav.ConditionalReaching, alt, action) } else if len(command) >= 5 && command[1] == 'D' { return s.DirectFix(tcw, callsign, command[2:], av.TurnRight, delayReduction) } else if l := len(command); l > 2 && command[l-1] == 'D' { diff --git a/sim/control_test.go b/sim/control_test.go index e108cc4c0..951e3f57c 100644 --- a/sim/control_test.go +++ b/sim/control_test.go @@ -607,3 +607,51 @@ func TestRunOneControlCommandLVRejectsMalformed(t *testing.T) { }) } } + +func TestRunOneControlCommandRC(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 5000, 10000) + intent, err := s.runOneControlCommand(tcw, callsign, "RC100/DAAC", 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := intent.(av.ConditionalCommandIntent); !ok { + t.Fatalf("expected ConditionalCommandIntent, got %T", intent) + } + ac := s.Aircraft[callsign] + if ac.Nav.PendingConditionalCommand == nil { + t.Fatalf("slot not installed") + } + if ac.Nav.PendingConditionalCommand.Altitude != 10000 { + t.Fatalf("wrong altitude %v", ac.Nav.PendingConditionalCommand.Altitude) + } + if ac.Nav.PendingConditionalCommand.Kind != nav.ConditionalReaching { + t.Fatalf("wrong kind %v", ac.Nav.PendingConditionalCommand.Kind) + } +} + +func TestRunOneControlCommandRCRejectsMalformed(t *testing.T) { + cases := []struct { + cmd string + wantSyntax bool + }{ + {"RC", true}, // bare command, too short + {"RC100H010", true}, // missing slash + {"RC/H010", true}, // empty altitude + {"RC100/", true}, // empty inner + {"RCABC/H010", false}, // non-numeric altitude (strconv error) + {"RC100/X010", true}, // unknown inner command + {"RC100/C50", true}, // altitude-changing inner rejected by parseConditionalAction + } + for _, tc := range cases { + t.Run(tc.cmd, func(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 5000, 10000) + _, err := s.runOneControlCommand(tcw, callsign, tc.cmd, 0) + if err == nil { + t.Fatalf("expected error for %q, got nil", tc.cmd) + } + if tc.wantSyntax && !errors.Is(err, ErrInvalidCommandSyntax) { + t.Fatalf("expected ErrInvalidCommandSyntax for %q, got %v", tc.cmd, err) + } + }) + } +} From f510ceb54ced9f22295f1e1fbed532b0d609027c Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:14:46 -0400 Subject: [PATCH 19/26] sim: fire LV/RC conditional commands when altitude trigger is met Extract fireConditionalIfTriggered helper so the per-second update loop runs ConditionalTriggered and, when true, silently executes the deferred action and clears the slot. The slot clears before Execute so that a follow-on conditional installed from within Execute doesn't fire on the same tick. Co-Authored-By: Claude Opus 4.7 --- sim/control_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++++ sim/sim.go | 23 +++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/sim/control_test.go b/sim/control_test.go index 951e3f57c..44d5b4f91 100644 --- a/sim/control_test.go +++ b/sim/control_test.go @@ -12,6 +12,7 @@ import ( "github.com/mmp/vice/log" "github.com/mmp/vice/math" "github.com/mmp/vice/nav" + "github.com/mmp/vice/rand" ) func TestParseHold(t *testing.T) { @@ -655,3 +656,70 @@ func TestRunOneControlCommandRCRejectsMalformed(t *testing.T) { }) } } + +func TestFireConditionalIfTriggeredFiresAndClearsSlot(t *testing.T) { + // Aircraft climbing through 3000 with a pending LV 3000/H010 command. + s, callsign, _ := setupTestSimWithAircraftAt(t, 3100, 7000) + ac := s.Aircraft[callsign] + ac.NASFlightPlan = &NASFlightPlan{} // make IsAssociated() return true + ac.Nav.Rand = rand.Make() // needed by EnqueueHeading for pilot-delay jitter + ac.Nav.FlightState.AltitudeRate = 500 // climbing + ac.Nav.PendingConditionalCommand = &nav.PendingConditionalCommand{ + Kind: nav.ConditionalLeaving, + Altitude: 3000, + Action: nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest}, + } + + s.fireConditionalIfTriggered(ac, av.Temperature{}) + + if ac.Nav.PendingConditionalCommand != nil { + t.Fatalf("expected slot cleared after firing, still got %+v", ac.Nav.PendingConditionalCommand) + } + if hdg, ok := ac.Nav.AssignedHeading(); !ok || hdg != 10 { + t.Fatalf("expected assigned heading 10, got ok=%v hdg=%v", ok, hdg) + } +} + +func TestFireConditionalIfTriggeredHoldsSlotWhenNotTriggered(t *testing.T) { + // Aircraft at 2000 climbing — has not yet reached 3000 trigger. + s, callsign, _ := setupTestSimWithAircraftAt(t, 2000, 7000) + ac := s.Aircraft[callsign] + ac.NASFlightPlan = &NASFlightPlan{} // make IsAssociated() return true + ac.Nav.FlightState.AltitudeRate = 500 + pc := &nav.PendingConditionalCommand{ + Kind: nav.ConditionalLeaving, + Altitude: 3000, + Action: nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest}, + } + ac.Nav.PendingConditionalCommand = pc + + s.fireConditionalIfTriggered(ac, av.Temperature{}) + + if ac.Nav.PendingConditionalCommand != pc { + t.Fatalf("expected slot still installed (not triggered yet)") + } + if _, ok := ac.Nav.AssignedHeading(); ok { + t.Fatalf("expected no heading assigned before trigger fires") + } +} + +func TestFireConditionalIfTriggeredSkipsWhenUnassociated(t *testing.T) { + // Setup sim and aircraft state that WOULD trigger, but aircraft has no + // NASFlightPlan so IsAssociated() returns false. + s, callsign, _ := setupTestSimWithAircraftAt(t, 3100, 7000) + ac := s.Aircraft[callsign] + // NASFlightPlan is nil by default from setupTestSimWithAircraftAt — unassociated. + ac.Nav.FlightState.AltitudeRate = 500 + pc := &nav.PendingConditionalCommand{ + Kind: nav.ConditionalLeaving, + Altitude: 3000, + Action: nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest}, + } + ac.Nav.PendingConditionalCommand = pc + + s.fireConditionalIfTriggered(ac, av.Temperature{}) + + if ac.Nav.PendingConditionalCommand != pc { + t.Fatalf("expected slot preserved when unassociated") + } +} diff --git a/sim/sim.go b/sim/sim.go index b74980726..a1d7a3f8a 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -1477,6 +1477,11 @@ func (s *Sim) updateState() { s.enqueuePilotTransmission(callsign, TCP(ac.ControllerFrequency), PendingTransmissionRequestVectors) } + if ac.Nav.PendingConditionalCommand != nil { + temp := s.wxModel.Lookup(ac.Nav.FlightState.Position, ac.Nav.FlightState.Altitude, s.State.SimTime.Time()).Temperature() + s.fireConditionalIfTriggered(ac, temp) + } + if ac.FirstSeen.IsZero() && s.isRadarVisible(ac) { ac.FirstSeen = s.State.SimTime } @@ -2422,3 +2427,21 @@ func (s *Sim) AnnotateFlightStrip(tcw TCW, acid ACID, annotations [9]string) err fp.StripAnnotations = annotations return nil } + +// fireConditionalIfTriggered executes and clears the aircraft's pending +// conditional command if the trigger condition is now met. The slot is +// cleared BEFORE Execute runs so a mis-parsed inner command that installs +// another conditional cannot loop. temp is only consulted by the Mach +// variant; other actions ignore it. +func (s *Sim) fireConditionalIfTriggered(ac *Aircraft, temp av.Temperature) { + pc := ac.Nav.PendingConditionalCommand + if pc == nil || !ac.IsAssociated() { + return + } + if !nav.ConditionalTriggered(&ac.Nav, pc) { + return + } + action := pc.Action + ac.Nav.PendingConditionalCommand = nil + action.Execute(&ac.Nav, s.State.SimTime.NavTime(), temp) +} From d9de8b7b745f661a810f0b152139541ea95927a8 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:17:18 -0400 Subject: [PATCH 20/26] sim: clarify fireConditionalIfTriggered comment Review noted the guard protects against a follow-on conditional installed by Execute rather than a mis-parse. Match the ConditionalAction interface doc which describes this as intentional re-entrancy safety. Co-Authored-By: Claude Opus 4.7 --- sim/sim.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sim/sim.go b/sim/sim.go index a1d7a3f8a..da335ed73 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -2430,9 +2430,9 @@ func (s *Sim) AnnotateFlightStrip(tcw TCW, acid ACID, annotations [9]string) err // fireConditionalIfTriggered executes and clears the aircraft's pending // conditional command if the trigger condition is now met. The slot is -// cleared BEFORE Execute runs so a mis-parsed inner command that installs -// another conditional cannot loop. temp is only consulted by the Mach -// variant; other actions ignore it. +// cleared BEFORE Execute runs so a follow-on conditional installed by +// Execute cannot fire on the same tick. temp is only consulted by the +// Mach variant; other actions ignore it. func (s *Sim) fireConditionalIfTriggered(ac *Aircraft, temp av.Temperature) { pc := ac.Nav.PendingConditionalCommand if pc == nil || !ac.IsAssociated() { From fabd8229813596a629898bf9ed1f1b2f12c59cd0 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:29:57 -0400 Subject: [PATCH 21/26] =?UTF-8?q?stt:=20recognize=20"leaving|passing=20{al?= =?UTF-8?q?t},=20{inner}"=20=E2=86=92=20LV{alt}/{inner}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers conditional-leaving patterns for heading, direct-fix, speed, and mach inner commands. Each template mirrors the phraseology of its non-conditional sibling. Implementation notes: - "leaving" is a fillerWords entry (prevents fuzzy match with "heading"), so two companion fixes are required: - parse.go: check for leaving|passing + altitude BEFORE the filler-skip loop, same pattern as the existing "then" and "at {altitude}" checks - matcher.go: literalMatcher filler-skip loop now exempts a token that exactly matches one of the matcher's own target keywords, so the "leaving|passing" literal can match its own keyword even though "leaving" is in fillerWords Co-Authored-By: Claude Opus 4.7 --- stt/handlers.go | 93 ++++++++++++++++++++++++++++++++++++++++++++ stt/matcher.go | 17 ++++++-- stt/parse.go | 24 ++++++++++++ stt/provider_test.go | 71 +++++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 3 deletions(-) diff --git a/stt/handlers.go b/stt/handlers.go index 0d1a56b24..e758407e6 100644 --- a/stt/handlers.go +++ b/stt/handlers.go @@ -1463,4 +1463,97 @@ func registerAllCommands() { WithName("airport_in_sight_inquiry"), WithPriority(10), ) + + // === CONDITIONAL COMMANDS: LEAVING/PASSING {alt}, {inner} === + // Fires the inner command when aircraft crosses the given altitude. + + // LV{alt}/H{hdg}: "leaving three thousand fly heading 010" + registerSTTCommand( + "leaving|passing {altitude} fly heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("LV%d/H%03d", alt, hdg) }, + WithName("conditional_lv_fly_heading"), + WithPriority(13), + ) + registerSTTCommand( + "leaving|passing {altitude} heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("LV%d/H%03d", alt, hdg) }, + WithName("conditional_lv_heading"), + WithPriority(13), + ) + + // LV{alt}/L{hdg}, LV{alt}/R{hdg}: "leaving five thousand turn left 270" + registerSTTCommand( + "leaving|passing {altitude} [turn] [to] left {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("LV%d/L%03d", alt, hdg) }, + WithName("conditional_lv_turn_left_heading"), + WithPriority(13), + ) + registerSTTCommand( + "leaving|passing {altitude} [turn] [to] right {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("LV%d/R%03d", alt, hdg) }, + WithName("conditional_lv_turn_right_heading"), + WithPriority(13), + ) + + // LV{alt}/L{deg}D, LV{alt}/R{deg}D: "leaving three thousand turn left 20 degrees" + registerSTTCommand( + "leaving|passing {altitude} turn {degrees}", + func(alt int, dr degreesResult) string { + dir := "L" + if dr.direction == "right" { + dir = "R" + } + return fmt.Sprintf("LV%d/%s%dD", alt, dir, dr.degrees) + }, + WithName("conditional_lv_turn_degrees"), + WithPriority(13), + ) + + // LV{alt}/D{fix}, LV{alt}/LD{fix}, LV{alt}/RD{fix} + registerSTTCommand( + "leaving|passing {altitude} direct|proceed [direct] [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("LV%d/D%s", alt, fix) }, + WithName("conditional_lv_direct"), + WithPriority(13), + ) + registerSTTCommand( + "leaving|passing {altitude} [proceed] left [turn] direct [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("LV%d/LD%s", alt, fix) }, + WithName("conditional_lv_left_direct"), + WithPriority(14), + ) + registerSTTCommand( + "leaving|passing {altitude} [proceed] right [turn] direct [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("LV%d/RD%s", alt, fix) }, + WithName("conditional_lv_right_direct"), + WithPriority(14), + ) + + // LV{alt}/S{spd}: "leaving five thousand reduce speed to 210" + registerSTTCommand( + "leaving|passing {altitude} reduce|slow [speed] [to] {speed}", + func(alt int, spd int) string { return fmt.Sprintf("LV%d/S%d", alt, spd) }, + WithName("conditional_lv_reduce_speed"), + WithPriority(13), + ) + registerSTTCommand( + "leaving|passing {altitude} maintain|increase [speed] [to] {speed}", + func(alt int, spd int) string { return fmt.Sprintf("LV%d/S%d", alt, spd) }, + WithName("conditional_lv_maintain_speed"), + WithPriority(13), + ) + + // LV{alt}/M{mach}: "leaving flight level 300 maintain mach 78" + registerSTTCommand( + "leaving|passing {altitude} maintain mach [point] {mach}", + func(alt int, mach int) string { return fmt.Sprintf("LV%d/M%d", alt, mach) }, + WithName("conditional_lv_maintain_mach"), + WithPriority(13), + ) + registerSTTCommand( + "leaving|passing {altitude} mach [point] {mach}", + func(alt int, mach int) string { return fmt.Sprintf("LV%d/M%d", alt, mach) }, + WithName("conditional_lv_mach"), + WithPriority(13), + ) } diff --git a/stt/matcher.go b/stt/matcher.go index 0ef5c494b..e28d709da 100644 --- a/stt/matcher.go +++ b/stt/matcher.go @@ -37,12 +37,23 @@ func (m *literalMatcher) match(tokens []Token, pos int, ac Aircraft, skipWords [ return matchResult{} } - // Skip filler words + // Skip filler words, but don't skip a token that exactly matches one of this + // matcher's target keywords. This allows filler-listed words like "leaving" to + // be recognized when used as actual command keywords (e.g., in "leaving|passing"). for pos < len(tokens) { text := strings.ToLower(tokens[pos].Text) if IsFillerWord(text) { - pos++ - continue + isOwnKeyword := false + for _, kw := range m.keywords { + if text == kw { + isOwnKeyword = true + break + } + } + if !isOwnKeyword { + pos++ + continue + } } break } diff --git a/stt/parse.go b/stt/parse.go index 58aafad3c..ec4f61e37 100644 --- a/stt/parse.go +++ b/stt/parse.go @@ -36,6 +36,30 @@ func ParseCommands(tokens []Token, ac Aircraft) ([]string, float64) { continue } + // Check for "leaving|passing {altitude} ..." pattern BEFORE filler-word skip. + // "leaving" is a filler word (to prevent fuzzy match with "heading"), but it + // also starts the conditional LV command. When followed by an altitude, treat + // it as a command keyword rather than filler. + if (tokens[pos].Text == "leaving" || tokens[pos].Text == "passing") && + pos+1 < len(tokens) && looksLikeAltitude(tokens[pos+1]) { + logLocalStt(" found %q before altitude at position %d, treating as LV command start", tokens[pos].Text, pos) + match, newPos := matchCommandNew(tokens, pos, ac, isThen, excludeCategories) + if newPos > pos { + logLocalStt(" matched LV command: %q (conf=%.2f, consumed=%d)", match.Command, match.Confidence, newPos-pos) + matchedAny = true + if match.Command != "" { + commands = append(commands, match.Command) + totalConf += match.Confidence + if category := getCommandCategory(match.Command); category != "" { + excludeCategories[category] = true + } + } + pos = newPos + isThen = false + continue + } + } + // Skip filler words if IsFillerWord(tokens[pos].Text) { logLocalStt(" skipping filler word: %q", tokens[pos].Text) diff --git a/stt/provider_test.go b/stt/provider_test.go index 2ddd05cb5..cdb4cc2ce 100644 --- a/stt/provider_test.go +++ b/stt/provider_test.go @@ -4151,3 +4151,74 @@ func TestNegativeWithoutCallsign(t *testing.T) { }) } } + +func TestSTTLeavingPatterns(t *testing.T) { + tests := []struct { + name string + transcript string + expected string + }{ + { + name: "leaving thousand fly heading", + transcript: "Delta 43 leaving three thousand fly heading zero one zero", + expected: "DAL43 LV30/H010", + }, + { + name: "passing thousand right heading", + transcript: "American 17 passing one three thousand right one zero zero", + expected: "AAL17 LV130/R100", + }, + { + name: "leaving thousand turn left heading", + transcript: "Delta 43 leaving five thousand turn left two seven zero", + expected: "DAL43 LV50/L270", + }, + { + name: "leaving thousand turn left degrees", + transcript: "Delta 43 leaving three thousand turn left twenty degrees", + expected: "DAL43 LV30/L20D", + }, + { + name: "leaving thousand turn right degrees", + transcript: "Delta 43 leaving three thousand turn right thirty degrees", + expected: "DAL43 LV30/R30D", + }, + { + name: "leaving thousand direct fix", + transcript: "Delta 43 leaving three thousand direct alpha alpha charlie", + expected: "DAL43 LV30/DAAC", + }, + { + name: "leaving thousand reduce speed", + transcript: "Delta 43 leaving five thousand reduce speed to two one zero", + expected: "DAL43 LV50/S210", + }, + } + + aircraft := map[string]Aircraft{ + "Delta 43": { + Callsign: "DAL43", + Altitude: 2000, + State: "departure", + Fixes: map[string]string{"alpha alpha charlie": "AAC"}, + }, + "American 17": { + Callsign: "AAL17", + Altitude: 10000, + State: "departure", + }, + } + + provider := NewTranscriber(nil) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := provider.DecodeTranscript(aircraft, tt.transcript, "") + if err != nil { + t.Fatalf("DecodeTranscript: %v", err) + } + if got != tt.expected { + t.Errorf("got %q, want %q", got, tt.expected) + } + }) + } +} From 7da135d85f5e4e6639e47313f6d290aaf5dec2e7 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:33:32 -0400 Subject: [PATCH 22/26] stt: cover the remaining LV inner commands with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds subtests for left-direct, right-direct, maintain-speed, and mach — four templates registered in fabd8229 that lacked explicit coverage. Co-Authored-By: Claude Opus 4.7 --- stt/provider_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/stt/provider_test.go b/stt/provider_test.go index cdb4cc2ce..63a730ef9 100644 --- a/stt/provider_test.go +++ b/stt/provider_test.go @@ -4193,6 +4193,26 @@ func TestSTTLeavingPatterns(t *testing.T) { transcript: "Delta 43 leaving five thousand reduce speed to two one zero", expected: "DAL43 LV50/S210", }, + { + name: "leaving thousand left direct fix", + transcript: "Delta 43 leaving three thousand left direct alpha alpha charlie", + expected: "DAL43 LV30/LDAAC", + }, + { + name: "leaving thousand right direct fix", + transcript: "Delta 43 leaving three thousand right direct alpha alpha charlie", + expected: "DAL43 LV30/RDAAC", + }, + { + name: "leaving thousand maintain speed", + transcript: "Delta 43 leaving five thousand maintain speed two five zero", + expected: "DAL43 LV50/S250", + }, + { + name: "leaving flight level maintain mach", + transcript: "American 17 leaving flight level three zero zero maintain mach point seven eight", + expected: "AAL17 LV300/M78", + }, } aircraft := map[string]Aircraft{ From c6fc1ca385b17d988c73cee62b7edcb7c905b725 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:38:59 -0400 Subject: [PATCH 23/26] =?UTF-8?q?stt:=20recognize=20"reaching|level=20at|o?= =?UTF-8?q?n=20reaching=20{alt},=20{inner}"=20=E2=86=92=20RC{alt}/{inner}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the LV section. "reaching", "level", "on", "at" are not filler words, so no parse.go or matcher.go changes are needed. The plan's single combined template "reaching|level at|on reaching" cannot be expressed as one template string because the parser splits alternations on spaces — multi-word phrases like "level at" decompose into two separate required matchers. Instead each inner command is registered twice: - "[on] reaching {altitude} ..." — covers "reaching X" and "on reaching X" - "level [at] {altitude} ..." — covers "level X" and "level at X" Co-Authored-By: Claude Opus 4.7 --- stt/handlers.go | 178 +++++++++++++++++++++++++++++++++++++++++++ stt/provider_test.go | 96 +++++++++++++++++++++++ 2 files changed, 274 insertions(+) diff --git a/stt/handlers.go b/stt/handlers.go index e758407e6..3e0687914 100644 --- a/stt/handlers.go +++ b/stt/handlers.go @@ -1556,4 +1556,182 @@ func registerAllCommands() { WithName("conditional_lv_mach"), WithPriority(13), ) + + // === CONDITIONAL COMMANDS: REACHING/LEVEL AT/ON REACHING {alt}, {inner} === + // Fires the inner command when aircraft first crosses within ~100 ft of the + // given altitude (ConditionalReaching). Mirror of the LV section above. + // + // "reaching|level at|on reaching" cannot be expressed as a single template + // because the template parser splits on spaces — "level at" and "on reaching" + // are two-word phrases. Instead each inner command is registered twice: + // - trigger_a: "[on] reaching {altitude} ..." (matches "reaching X" and "on reaching X") + // - trigger_b: "level [at] {altitude} ..." (matches "level X" and "level at X") + + // RC{alt}/H{hdg}: "reaching three thousand fly heading 010" + registerSTTCommand( + "[on] reaching {altitude} fly heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/H%03d", alt, hdg) }, + WithName("conditional_rc_fly_heading"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} fly heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/H%03d", alt, hdg) }, + WithName("conditional_rc_fly_heading_level"), + WithPriority(13), + ) + registerSTTCommand( + "[on] reaching {altitude} heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/H%03d", alt, hdg) }, + WithName("conditional_rc_heading"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/H%03d", alt, hdg) }, + WithName("conditional_rc_heading_level"), + WithPriority(13), + ) + + // RC{alt}/L{hdg}, RC{alt}/R{hdg}: "reaching five thousand turn left 270" + registerSTTCommand( + "[on] reaching {altitude} [turn] [to] left {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/L%03d", alt, hdg) }, + WithName("conditional_rc_turn_left_heading"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} [turn] [to] left {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/L%03d", alt, hdg) }, + WithName("conditional_rc_turn_left_heading_level"), + WithPriority(13), + ) + registerSTTCommand( + "[on] reaching {altitude} [turn] [to] right {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/R%03d", alt, hdg) }, + WithName("conditional_rc_turn_right_heading"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} [turn] [to] right {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/R%03d", alt, hdg) }, + WithName("conditional_rc_turn_right_heading_level"), + WithPriority(13), + ) + + // RC{alt}/L{deg}D, RC{alt}/R{deg}D: "reaching three thousand turn left 20 degrees" + registerSTTCommand( + "[on] reaching {altitude} turn {degrees}", + func(alt int, dr degreesResult) string { + dir := "L" + if dr.direction == "right" { + dir = "R" + } + return fmt.Sprintf("RC%d/%s%dD", alt, dir, dr.degrees) + }, + WithName("conditional_rc_turn_degrees"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} turn {degrees}", + func(alt int, dr degreesResult) string { + dir := "L" + if dr.direction == "right" { + dir = "R" + } + return fmt.Sprintf("RC%d/%s%dD", alt, dir, dr.degrees) + }, + WithName("conditional_rc_turn_degrees_level"), + WithPriority(13), + ) + + // RC{alt}/D{fix}, RC{alt}/LD{fix}, RC{alt}/RD{fix} + registerSTTCommand( + "[on] reaching {altitude} direct|proceed [direct] [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/D%s", alt, fix) }, + WithName("conditional_rc_direct"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} direct|proceed [direct] [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/D%s", alt, fix) }, + WithName("conditional_rc_direct_level"), + WithPriority(13), + ) + registerSTTCommand( + "[on] reaching {altitude} [proceed] left [turn] direct [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/LD%s", alt, fix) }, + WithName("conditional_rc_left_direct"), + WithPriority(14), + ) + registerSTTCommand( + "level [at] {altitude} [proceed] left [turn] direct [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/LD%s", alt, fix) }, + WithName("conditional_rc_left_direct_level"), + WithPriority(14), + ) + registerSTTCommand( + "[on] reaching {altitude} [proceed] right [turn] direct [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/RD%s", alt, fix) }, + WithName("conditional_rc_right_direct"), + WithPriority(14), + ) + registerSTTCommand( + "level [at] {altitude} [proceed] right [turn] direct [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/RD%s", alt, fix) }, + WithName("conditional_rc_right_direct_level"), + WithPriority(14), + ) + + // RC{alt}/S{spd}: "reaching five thousand reduce speed to 210" + registerSTTCommand( + "[on] reaching {altitude} reduce|slow [speed] [to] {speed}", + func(alt int, spd int) string { return fmt.Sprintf("RC%d/S%d", alt, spd) }, + WithName("conditional_rc_reduce_speed"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} reduce|slow [speed] [to] {speed}", + func(alt int, spd int) string { return fmt.Sprintf("RC%d/S%d", alt, spd) }, + WithName("conditional_rc_reduce_speed_level"), + WithPriority(13), + ) + registerSTTCommand( + "[on] reaching {altitude} maintain|increase [speed] [to] {speed}", + func(alt int, spd int) string { return fmt.Sprintf("RC%d/S%d", alt, spd) }, + WithName("conditional_rc_maintain_speed"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} maintain|increase [speed] [to] {speed}", + func(alt int, spd int) string { return fmt.Sprintf("RC%d/S%d", alt, spd) }, + WithName("conditional_rc_maintain_speed_level"), + WithPriority(13), + ) + + // RC{alt}/M{mach}: "reaching flight level 300 maintain mach 78" + registerSTTCommand( + "[on] reaching {altitude} maintain mach [point] {mach}", + func(alt int, mach int) string { return fmt.Sprintf("RC%d/M%d", alt, mach) }, + WithName("conditional_rc_maintain_mach"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} maintain mach [point] {mach}", + func(alt int, mach int) string { return fmt.Sprintf("RC%d/M%d", alt, mach) }, + WithName("conditional_rc_maintain_mach_level"), + WithPriority(13), + ) + registerSTTCommand( + "[on] reaching {altitude} mach [point] {mach}", + func(alt int, mach int) string { return fmt.Sprintf("RC%d/M%d", alt, mach) }, + WithName("conditional_rc_mach"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} mach [point] {mach}", + func(alt int, mach int) string { return fmt.Sprintf("RC%d/M%d", alt, mach) }, + WithName("conditional_rc_mach_level"), + WithPriority(13), + ) } diff --git a/stt/provider_test.go b/stt/provider_test.go index 63a730ef9..f3b8b2b55 100644 --- a/stt/provider_test.go +++ b/stt/provider_test.go @@ -4242,3 +4242,99 @@ func TestSTTLeavingPatterns(t *testing.T) { }) } } + +func TestSTTReachingPatterns(t *testing.T) { + tests := []struct { + name string + transcript string + expected string + }{ + { + name: "reaching thousand fly heading", + transcript: "Delta 43 reaching one zero thousand fly heading zero one zero", + expected: "DAL43 RC100/H010", + }, + { + name: "level at thousand heading", + transcript: "Delta 43 level at five thousand heading two seven zero", + expected: "DAL43 RC50/H270", + }, + { + name: "on reaching thousand turn left heading", + transcript: "Delta 43 on reaching three thousand turn left two seven zero", + expected: "DAL43 RC30/L270", + }, + { + name: "reaching thousand turn right heading", + transcript: "Delta 43 reaching five thousand turn right one eight zero", + expected: "DAL43 RC50/R180", + }, + { + name: "reaching thousand turn left degrees", + transcript: "Delta 43 reaching three thousand turn left twenty degrees", + expected: "DAL43 RC30/L20D", + }, + { + name: "reaching thousand turn right degrees", + transcript: "Delta 43 reaching three thousand turn right thirty degrees", + expected: "DAL43 RC30/R30D", + }, + { + name: "reaching thousand direct fix", + transcript: "Delta 43 reaching three thousand direct alpha alpha charlie", + expected: "DAL43 RC30/DAAC", + }, + { + name: "reaching thousand left direct fix", + transcript: "Delta 43 reaching three thousand left direct alpha alpha charlie", + expected: "DAL43 RC30/LDAAC", + }, + { + name: "reaching thousand right direct fix", + transcript: "Delta 43 reaching three thousand right direct alpha alpha charlie", + expected: "DAL43 RC30/RDAAC", + }, + { + name: "reaching thousand reduce speed", + transcript: "Delta 43 reaching five thousand reduce speed to two one zero", + expected: "DAL43 RC50/S210", + }, + { + name: "reaching thousand maintain speed", + transcript: "Delta 43 reaching five thousand maintain speed two five zero", + expected: "DAL43 RC50/S250", + }, + { + name: "reaching flight level maintain mach", + transcript: "American 17 reaching flight level three zero zero maintain mach point seven eight", + expected: "AAL17 RC300/M78", + }, + } + + aircraft := map[string]Aircraft{ + "Delta 43": { + Callsign: "DAL43", + Altitude: 15000, + State: "arrival", + Fixes: map[string]string{"alpha alpha charlie": "AAC"}, + }, + "American 17": { + Callsign: "AAL17", + Altitude: 35000, + State: "arrival", + }, + } + + provider := NewTranscriber(nil) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := provider.DecodeTranscript(aircraft, tt.transcript, "") + if err != nil { + t.Fatalf("DecodeTranscript: %v", err) + } + if got != tt.expected { + t.Errorf("got %q, want %q", got, tt.expected) + } + }) + } +} From e8565488218be93502408f5a17af9c12164ad7d8 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:43:52 -0400 Subject: [PATCH 24/26] docs: whatsnew entry for LV/RC conditional commands --- whatsnew.md | 1 + 1 file changed, 1 insertion(+) diff --git a/whatsnew.md b/whatsnew.md index 82d8773f3..20d3bedbb 100644 --- a/whatsnew.md +++ b/whatsnew.md @@ -8,6 +8,7 @@ - Fixed a few bugs with "at {fix}, cleared straight in {approach}" - Added "after {fix}, climb/descend and maintain {altitude}" - Added "after {fix}, reduce/maintain/increase {speed}" + - Added "leaving/reaching {altitude}, {action}" controller commands. Examples: `LV30/H010` ("leaving 3,000, fly heading 010"), `RC100/DAAC` ("reaching 10,000, direct AAC"). Supported inner actions: headings, turns by degrees, direct-to-fix, speed, and mach. - Added "speed {speed1} until {fix1}, then {speed2} until {fix2}, then {speed3}", etc. - Added "cross {fix} {miles} miles {direction} of {fix}" - Added "good rate" for climbs/descents: "descend and maintain 3,000, good rate through 5,000", etc. From de27d96915706ebf9afdbf3710f3a0600609c778 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:12:35 -0400 Subject: [PATCH 25/26] stt: emit SAYAGAIN when LV/RC direct-fix can't resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When "leaving|passing|reaching {alt} direct|proceed {fix}" matches through direct/proceed but the fix isn't in the aircraft's Fixes map, the template would previously fail silently and fall through to the standalone_altitude handler, producing a misleading A{alt} output (e.g., "reaching 7000 direct DANNY" → A70 when DANNY isn't on the route). Add WithSayAgainOnFail() to the 9 LV/RC direct-fix handlers so they emit SAYAGAIN/FIX instead. Guard with WithSayAgainMinTokens(3) to require keyword + altitude + direct/proceed to all match before triggering — prevents false positives where "leave" fuzzy-matches unrelated words in transcripts like "leave in november" without a following altitude. Co-Authored-By: Claude Opus 4.7 --- stt/handlers.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/stt/handlers.go b/stt/handlers.go index 3e0687914..06d640585 100644 --- a/stt/handlers.go +++ b/stt/handlers.go @@ -1510,23 +1510,35 @@ func registerAllCommands() { ) // LV{alt}/D{fix}, LV{alt}/LD{fix}, LV{alt}/RD{fix} + // SayAgainOnFail: when "leaving|passing {alt} direct|proceed" matches but + // {fix} can't be resolved, emit SAYAGAIN instead of silently falling through + // to the standalone_altitude handler (which would produce a misleading + // A{alt}). MinTokens(3) requires keyword+altitude+direct/proceed to all + // match before triggering — prevents false positives where "leave" or + // "passing" fuzzy-matches unrelated words. registerSTTCommand( "leaving|passing {altitude} direct|proceed [direct] [to] [at] {fix}", func(alt int, fix string) string { return fmt.Sprintf("LV%d/D%s", alt, fix) }, WithName("conditional_lv_direct"), WithPriority(13), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), ) registerSTTCommand( "leaving|passing {altitude} [proceed] left [turn] direct [to] [at] {fix}", func(alt int, fix string) string { return fmt.Sprintf("LV%d/LD%s", alt, fix) }, WithName("conditional_lv_left_direct"), WithPriority(14), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), ) registerSTTCommand( "leaving|passing {altitude} [proceed] right [turn] direct [to] [at] {fix}", func(alt int, fix string) string { return fmt.Sprintf("LV%d/RD%s", alt, fix) }, WithName("conditional_lv_right_direct"), WithPriority(14), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), ) // LV{alt}/S{spd}: "leaving five thousand reduce speed to 210" @@ -1646,41 +1658,58 @@ func registerAllCommands() { ) // RC{alt}/D{fix}, RC{alt}/LD{fix}, RC{alt}/RD{fix} + // SayAgainOnFail: when "reaching|level at {alt} direct|proceed" matches but + // {fix} can't be resolved, emit SAYAGAIN instead of silently falling + // through to the standalone_altitude handler (which would produce a + // misleading A{alt}). MinTokens(3) requires keyword+altitude+direct/proceed + // to all match before triggering. registerSTTCommand( "[on] reaching {altitude} direct|proceed [direct] [to] [at] {fix}", func(alt int, fix string) string { return fmt.Sprintf("RC%d/D%s", alt, fix) }, WithName("conditional_rc_direct"), WithPriority(13), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), ) registerSTTCommand( "level [at] {altitude} direct|proceed [direct] [to] [at] {fix}", func(alt int, fix string) string { return fmt.Sprintf("RC%d/D%s", alt, fix) }, WithName("conditional_rc_direct_level"), WithPriority(13), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), ) registerSTTCommand( "[on] reaching {altitude} [proceed] left [turn] direct [to] [at] {fix}", func(alt int, fix string) string { return fmt.Sprintf("RC%d/LD%s", alt, fix) }, WithName("conditional_rc_left_direct"), WithPriority(14), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), ) registerSTTCommand( "level [at] {altitude} [proceed] left [turn] direct [to] [at] {fix}", func(alt int, fix string) string { return fmt.Sprintf("RC%d/LD%s", alt, fix) }, WithName("conditional_rc_left_direct_level"), WithPriority(14), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), ) registerSTTCommand( "[on] reaching {altitude} [proceed] right [turn] direct [to] [at] {fix}", func(alt int, fix string) string { return fmt.Sprintf("RC%d/RD%s", alt, fix) }, WithName("conditional_rc_right_direct"), WithPriority(14), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), ) registerSTTCommand( "level [at] {altitude} [proceed] right [turn] direct [to] [at] {fix}", func(alt int, fix string) string { return fmt.Sprintf("RC%d/RD%s", alt, fix) }, WithName("conditional_rc_right_direct_level"), WithPriority(14), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), ) // RC{alt}/S{spd}: "reaching five thousand reduce speed to 210" From c29d18840f22ee43245dd4a01d45e8e13912cb0f Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:05:33 -0400 Subject: [PATCH 26/26] untrack docs/superpowers/ planning docs --- .../2026-04-20-leaving-reaching-commands.md | 1928 ----------------- ...-04-20-leaving-reaching-commands-design.md | 322 --- 2 files changed, 2250 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-20-leaving-reaching-commands.md delete mode 100644 docs/superpowers/specs/2026-04-20-leaving-reaching-commands-design.md diff --git a/docs/superpowers/plans/2026-04-20-leaving-reaching-commands.md b/docs/superpowers/plans/2026-04-20-leaving-reaching-commands.md deleted file mode 100644 index 9e69e3067..000000000 --- a/docs/superpowers/plans/2026-04-20-leaving-reaching-commands.md +++ /dev/null @@ -1,1928 +0,0 @@ -# Leaving/Reaching Altitude Conditional Commands Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add `LV{alt}/{inner}` (leaving) and `RC{alt}/{inner}` (reaching) controller commands that defer a lateral/speed/mach action until the aircraft crosses a trigger altitude. - -**Architecture:** Closed-set typed dispatch mirroring the existing `A{fix}/{inner}` pattern. A new `ConditionalAction` interface in `nav` with one concrete type per supported inner (heading, direct-fix, speed, mach). A single `PendingConditionalCommand` slot on `Nav`; `sim.updateState` fires it silently when the trigger condition is met. - -**Tech Stack:** Go 1.x, existing vice sim/nav/aviation/stt packages. `go test ./...` for verification. - -**Spec:** `docs/superpowers/specs/2026-04-20-leaving-reaching-commands-design.md` - ---- - -## File map - -**New files:** -- `nav/conditional.go` — `ConditionalKind`, `ConditionalAction` interface, concrete action types, `conditionalTriggered` predicate -- `nav/conditional_test.go` — unit tests for actions and trigger predicate - -**Modified files:** -- `nav/nav.go` — add `PendingConditionalCommand *PendingConditionalCommand` field to `Nav` -- `aviation/intent.go` — add `ConditionalCommandIntent` near the other special intents -- `sim/control.go` — add `parseConditionalAltitude`, `parseConditionalAction`, `triggerReachable`, `AssignConditional`; add dispatch branches in cases `'L'` and `'R'` -- `sim/control_test.go` — integration tests for dispatch and rejection paths -- `sim/sim.go` — add trigger check in `updateState` -- `sim/e2e_test.go` — end-to-end tick-through scenarios -- `stt/handlers.go` — register voice patterns for both triggers and each inner command -- `stt/handlers_test.go` — STT happy-path and adversarial tests -- `whatsnew.md` — user-visible changelog entry - -**Commit cadence:** one commit per task. Every commit must leave `go test ./sim/... ./nav/... ./stt/... ./aviation/...` green. - ---- - -### Task 1: `ConditionalKind`, `ConditionalAction` interface, `PendingConditionalCommand` struct, Nav field - -**Files:** -- Create: `nav/conditional.go` -- Create: `nav/conditional_test.go` -- Modify: `nav/nav.go` (add one field inside `Nav`) - -- [ ] **Step 1: Write the failing test** - -Create `nav/conditional_test.go`: - -```go -package nav - -import ( - "testing" -) - -func TestNavHasPendingConditionalCommandField(t *testing.T) { - var n Nav - if n.PendingConditionalCommand != nil { - t.Fatalf("PendingConditionalCommand should default to nil, got %+v", n.PendingConditionalCommand) - } - n.PendingConditionalCommand = &PendingConditionalCommand{ - Kind: ConditionalLeaving, - Altitude: 3000, - } - if n.PendingConditionalCommand.Kind != ConditionalLeaving { - t.Fatalf("expected ConditionalLeaving, got %d", n.PendingConditionalCommand.Kind) - } - if n.PendingConditionalCommand.Altitude != 3000 { - t.Fatalf("expected 3000, got %v", n.PendingConditionalCommand.Altitude) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./nav/... -run TestNavHasPendingConditionalCommandField -v` -Expected: FAIL — undefined `PendingConditionalCommand`, `ConditionalLeaving`. - -- [ ] **Step 3: Create `nav/conditional.go`** - -```go -// nav/conditional.go -// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. -// SPDX: GPL-3.0-only - -package nav - -import ( - "math/rand/v2" - - av "github.com/mmp/vice/aviation" -) - -// ConditionalKind identifies which altitude-event triggers the deferred action. -type ConditionalKind uint8 - -const ( - // ConditionalLeaving fires once the aircraft's altitude has passed the - // trigger by more than a small tolerance in the direction of current - // vertical motion. - ConditionalLeaving ConditionalKind = iota - - // ConditionalReaching fires on first contact within 100 ft of the trigger - // altitude, regardless of vertical rate. - ConditionalReaching -) - -// ConditionalAction is the deferred action to execute when a LV/RC trigger -// fires. Concrete types cover the closed set of supported inner commands -// (heading, direct-fix, speed, mach). -type ConditionalAction interface { - // Execute mutates nav to carry out the deferred action. Called with the - // PendingConditionalCommand slot already cleared, so re-entry is safe. - Execute(nav *Nav, simTime Time) - - // Render emits the action-specific readback fragment (e.g., "fly heading - // 010") used inside ConditionalCommandIntent. - Render(rt *av.RadioTransmission, r *rand.Rand) -} - -// PendingConditionalCommand is the single slot on Nav that stores a -// deferred LV/RC action. A new LV/RC command supersedes any prior slot; -// successful trigger firing clears it. -type PendingConditionalCommand struct { - Kind ConditionalKind - Altitude float32 // feet MSL - Action ConditionalAction -} -``` - -- [ ] **Step 4: Add the field to `Nav` in `nav/nav.go`** - -In `nav/nav.go`, add the following field to the `Nav` struct near the existing `ReportReachingAltitude` field: - -```go - // PendingConditionalCommand stores a single deferred LV/RC action - // (e.g., "leaving 3,000, fly heading 010"). Cleared when the trigger - // fires or when a new LV/RC command is installed. Not cleared on - // new altitude/heading/speed assignments or on handoff. - PendingConditionalCommand *PendingConditionalCommand -``` - -- [ ] **Step 5: Run test to verify it passes** - -Run: `go test ./nav/... -run TestNavHasPendingConditionalCommandField -v` -Expected: PASS. - -Also run the full nav suite to verify no regression: `go test ./nav/...` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add nav/conditional.go nav/conditional_test.go nav/nav.go -git commit -m "nav: add ConditionalAction interface and PendingConditionalCommand slot" -``` - ---- - -### Task 2: `ConditionalHeading` with Execute and Render - -**Files:** -- Modify: `nav/conditional.go` -- Modify: `nav/conditional_test.go` - -- [ ] **Step 1: Write the failing test** - -Append to `nav/conditional_test.go`: - -```go -import ( - // ...existing imports... - av "github.com/mmp/vice/aviation" - "github.com/mmp/vice/math" - "math/rand/v2" -) - -func TestConditionalHeadingExecuteClosest(t *testing.T) { - // Aircraft flying a heading; conditional heading assigns 010. - n := makeTestNav(t, 180) // helper: Nav with current heading 180, altitude 2000, etc. - action := ConditionalHeading{Heading: 10, Turn: av.TurnClosest} - action.Execute(&n, Time{}) - if assigned, ok := n.AssignedHeading(); !ok || assigned != 10 { - t.Fatalf("expected assigned heading 10, got ok=%v heading=%v", ok, assigned) - } -} - -func TestConditionalHeadingExecuteByDegreesLeft(t *testing.T) { - n := makeTestNav(t, 180) - action := ConditionalHeading{ByDegrees: 30, Turn: av.TurnLeft} - action.Execute(&n, Time{}) - // TurnLeft 30 from 180 -> 150 - if assigned, ok := n.AssignedHeading(); !ok || assigned != 150 { - t.Fatalf("expected assigned heading 150, got ok=%v heading=%v", ok, assigned) - } -} - -func TestConditionalHeadingRender(t *testing.T) { - cases := []struct { - action ConditionalHeading - want string // substring in written form - }{ - {ConditionalHeading{Heading: 10, Turn: av.TurnClosest}, "010"}, - {ConditionalHeading{Heading: 100, Turn: av.TurnRight}, "right"}, - {ConditionalHeading{Heading: 100, Turn: av.TurnLeft}, "left"}, - {ConditionalHeading{ByDegrees: 20, Turn: av.TurnLeft}, "left 20"}, - } - r := rand.New(rand.NewPCG(1, 2)) - for _, tc := range cases { - rt := &av.RadioTransmission{} - tc.action.Render(rt, r) - written := rt.Written(r) - if !strings.Contains(strings.ToLower(written), strings.ToLower(tc.want)) { - t.Errorf("Render(%+v) = %q; want containing %q", tc.action, written, tc.want) - } - } -} -``` - -Add a test helper at the bottom of `nav/conditional_test.go` (or reuse an existing builder if present — search `nav/*_test.go` for `makeTestNav` or `newTestNav`): - -```go -func makeTestNav(t *testing.T, heading math.MagneticHeading) Nav { - t.Helper() - n := Nav{ - Rand: rand.New(rand.NewPCG(1, 2)), - } - n.FlightState.Heading = heading - n.FlightState.Altitude = 2000 - return n -} -``` - -If the test builder doesn't compile because `FlightState` lives elsewhere or needs other fields for `AssignHeading` to work, read `nav/commands_test.go` for the existing test-nav-builder pattern and reuse it instead of inventing a new one. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./nav/... -run TestConditionalHeading -v` -Expected: FAIL — `ConditionalHeading` undefined. - -- [ ] **Step 3: Implement `ConditionalHeading`** - -Append to `nav/conditional.go`: - -```go -// ConditionalHeading is a deferred heading assignment. Exactly one of -// Heading or ByDegrees is nonzero: -// - Heading != 0 → fly (or turn to) the absolute heading. -// - ByDegrees != 0 → turn N degrees from present heading in the given -// direction (Turn must be TurnLeft or TurnRight). -type ConditionalHeading struct { - Heading int // 1..360, 0 if unused - Turn av.TurnDirection // TurnClosest, TurnLeft, TurnRight - ByDegrees int // nonzero for LnnD / RnnD -} - -func (c ConditionalHeading) Execute(nav *Nav, simTime Time) { - if c.ByDegrees != 0 { - switch c.Turn { - case av.TurnLeft: - nav.assignHeading( - nav.FlightState.Heading.Turn(float32(-c.ByDegrees)), - av.TurnLeft, simTime, 0) - case av.TurnRight: - nav.assignHeading( - nav.FlightState.Heading.Turn(float32(c.ByDegrees)), - av.TurnRight, simTime, 0) - } - return - } - nav.assignHeading(math.MagneticHeading(c.Heading), c.Turn, simTime, 0) -} - -func (c ConditionalHeading) Render(rt *av.RadioTransmission, r *rand.Rand) { - if c.ByDegrees != 0 { - switch c.Turn { - case av.TurnLeft: - rt.Add("[left|turn left] {num} degrees", c.ByDegrees) - case av.TurnRight: - rt.Add("[right|turn right] {num} degrees", c.ByDegrees) - } - return - } - switch c.Turn { - case av.TurnLeft: - rt.Add("[left heading|turn left heading] {hdg}", c.Heading) - case av.TurnRight: - rt.Add("[right heading|turn right heading] {hdg}", c.Heading) - default: - rt.Add("[fly heading|heading] {hdg}", c.Heading) - } -} -``` - -Note: `nav.assignHeading` (lowercase, the internal helper at `nav/commands.go:473`) is used directly to avoid the validation in the public `AssignHeading` that we don't need here (validation already happened at command-issue time). `nav.FlightState.Heading.Turn(deg float32)` is the existing heading-math helper. - -Verify the helper exists: `grep -n "func (h MagneticHeading) Turn" math/math.go`. If the signature differs, adjust the call. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./nav/... -run TestConditionalHeading -v` -Expected: PASS. - -Run: `go test ./nav/...` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add nav/conditional.go nav/conditional_test.go -git commit -m "nav: add ConditionalHeading action with Execute and Render" -``` - ---- - -### Task 3: `ConditionalDirectFix` with Execute and Render - -**Files:** -- Modify: `nav/conditional.go` -- Modify: `nav/conditional_test.go` - -- [ ] **Step 1: Write the failing test** - -Append to `nav/conditional_test.go`: - -```go -func TestConditionalDirectFixExecute(t *testing.T) { - n := makeTestNavWithRoute(t, "AAC") // helper: Nav whose Waypoints contains fix "AAC" - action := ConditionalDirectFix{Fix: "AAC", Turn: av.TurnClosest} - action.Execute(&n, Time{}) - // After direct-fix, the first waypoint should be the target fix. - if len(n.Waypoints) == 0 || n.Waypoints[0].Fix != "AAC" { - t.Fatalf("expected first waypoint AAC, got %+v", n.Waypoints) - } -} - -func TestConditionalDirectFixRender(t *testing.T) { - cases := []struct { - action ConditionalDirectFix - want string - }{ - {ConditionalDirectFix{Fix: "AAC", Turn: av.TurnClosest}, "direct"}, - {ConditionalDirectFix{Fix: "AAC", Turn: av.TurnLeft}, "left"}, - {ConditionalDirectFix{Fix: "AAC", Turn: av.TurnRight}, "right"}, - } - r := rand.New(rand.NewPCG(1, 2)) - for _, tc := range cases { - rt := &av.RadioTransmission{} - tc.action.Render(rt, r) - written := strings.ToLower(rt.Written(r)) - if !strings.Contains(written, strings.ToLower(tc.want)) { - t.Errorf("Render(%+v) = %q; want containing %q", tc.action, written, tc.want) - } - } -} -``` - -Add helper `makeTestNavWithRoute` — model it on `makeTestNav` plus whatever `nav/commands_test.go` does to set up a Nav with a named waypoint. If an equivalent helper already exists (e.g., `newNavWithFix`), reuse that. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./nav/... -run TestConditionalDirectFix -v` -Expected: FAIL — undefined `ConditionalDirectFix`. - -- [ ] **Step 3: Implement `ConditionalDirectFix`** - -Append to `nav/conditional.go`: - -```go -// ConditionalDirectFix is a deferred direct-to-fix instruction. -type ConditionalDirectFix struct { - Fix string - Turn av.TurnDirection // TurnClosest, TurnLeft, TurnRight -} - -func (c ConditionalDirectFix) Execute(nav *Nav, simTime Time) { - // Call the internal direct-fix path. The public DirectFix returns an - // intent we don't need since execution is silent. - _ = nav.directFix(c.Fix, c.Turn, simTime, 0) -} - -func (c ConditionalDirectFix) Render(rt *av.RadioTransmission, r *rand.Rand) { - switch c.Turn { - case av.TurnLeft: - rt.Add("[left direct|turn left direct] {fix}", c.Fix) - case av.TurnRight: - rt.Add("[right direct|turn right direct] {fix}", c.Fix) - default: - rt.Add("[direct|proceed direct] {fix}", c.Fix) - } -} -``` - -If `directFix` (lowercase) doesn't exist as an internal helper, split the public `DirectFix` to carve one out. Verify: `grep -n "func (nav \*Nav) directFix\|func (nav \*Nav) DirectFix" nav/*.go`. The public signature is `DirectFix(fix string, turn av.TurnDirection, simTime Time, delayReduction time.Duration) av.CommandIntent` per `nav/commands.go:647`. If no lowercase internal helper exists, calling the public `DirectFix` and discarding the intent is fine — include a comment explaining that the return value is intentionally discarded because the silent-fire path doesn't read back. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./nav/... -run TestConditionalDirectFix -v` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add nav/conditional.go nav/conditional_test.go -git commit -m "nav: add ConditionalDirectFix action" -``` - ---- - -### Task 4: `ConditionalSpeed` and `ConditionalMach` - -**Files:** -- Modify: `nav/conditional.go` -- Modify: `nav/conditional_test.go` - -- [ ] **Step 1: Write the failing tests** - -Append to `nav/conditional_test.go`: - -```go -func TestConditionalSpeedExecute(t *testing.T) { - n := makeTestNav(t, 180) - sr := av.MakeExactSpeedRestriction(210) - action := ConditionalSpeed{Restriction: sr} - action.Execute(&n, Time{}) - if n.Speed.Assigned == nil { - t.Fatalf("expected Speed.Assigned set, got nil") - } - if got, _ := n.Speed.Assigned.ExactValue(); got != 210 { - t.Fatalf("expected 210, got %v", got) - } -} - -func TestConditionalMachExecute(t *testing.T) { - n := makeTestNav(t, 180) - n.FlightState.Altitude = 30000 - action := ConditionalMach{Mach: 0.78} - // ConditionalMach.Execute needs a temperature lookup. The production - // path gets temp from the sim's weather model; for the test we can - // accept that Execute takes temp from nav.FlightState.Temperature (or - // whatever the field is). If no such field exists, Execute must be - // passed temp some other way — adjust the action shape accordingly. - action.Execute(&n, Time{}) - // Assert the nav state was updated — exact assertion depends on how - // AssignMach is observable on Nav (probably Speed.Assigned with IsMach - // set). Inspect nav/commands.go:129 for the surface and assert on it. -} -``` - -Look at `nav/commands.go:129` for `AssignMach(mach float32, afterAltitude bool, temp av.Temperature) av.CommandIntent` to understand what temperature is expected and which Nav state it mutates. The test assertion should match that surface. - -Validate `av.MakeExactSpeedRestriction` exists: `grep -n "func MakeExactSpeedRestriction" aviation/*.go`. If the constructor is named differently (e.g., `NewSpeedRestriction`, `ParseSpeedRestriction`), adjust. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./nav/... -run "TestConditional(Speed|Mach)" -v` -Expected: FAIL — undefined types. - -- [ ] **Step 3: Implement `ConditionalSpeed` and `ConditionalMach`** - -Append to `nav/conditional.go`: - -```go -// ConditionalSpeed is a deferred speed assignment. -type ConditionalSpeed struct { - Restriction av.SpeedRestriction -} - -func (c ConditionalSpeed) Execute(nav *Nav, simTime Time) { - sr := c.Restriction - _ = nav.AssignSpeed(&sr, false) -} - -func (c ConditionalSpeed) Render(rt *av.RadioTransmission, r *rand.Rand) { - spd, _ := c.Restriction.ExactValue() - rt.Add("[reduce speed to|maintain|slowing to] {spd}", spd) -} - -// ConditionalMach is a deferred mach-speed assignment. -type ConditionalMach struct { - Mach float32 -} - -func (c ConditionalMach) Execute(nav *Nav, simTime Time) { - // Mach execution requires a temperature. Use the nav's recorded temperature - // at the flight level; this is an approximation, since the production - // AssignMach path queries a live weather model, but it's acceptable here - // because the deferred action fires when we've just reached the target - // altitude, which is close to the altitude the controller was considering - // when issuing the command. - _ = nav.AssignMach(c.Mach, false, nav.FlightState.Temperature) -} - -func (c ConditionalMach) Render(rt *av.RadioTransmission, r *rand.Rand) { - rt.Add("[mach|maintain mach] {mach}", c.Mach) -} -``` - -If `nav.FlightState.Temperature` doesn't exist, check `nav/nav.go` around the `FlightState` definition for how temperature is exposed. If it's not a field on FlightState, either: -- Add a `Temperature` parameter to `ConditionalAction.Execute(...)` (requires threading through `sim.updateState`), or -- Look the temperature up via the sim's weather model when firing, before calling `Execute`. - -The second option is cleaner if `Execute` grows a temperature parameter: change the interface to `Execute(nav *Nav, simTime Time, temp av.Temperature)` and pass a zero temperature for non-mach actions. Verify `av.Temperature` type: `grep -n "type Temperature" aviation/*.go`. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./nav/... -run "TestConditional(Speed|Mach)" -v` -Expected: PASS. - -Run: `go test ./nav/...` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add nav/conditional.go nav/conditional_test.go -git commit -m "nav: add ConditionalSpeed and ConditionalMach actions" -``` - ---- - -### Task 5: Trigger predicate `conditionalTriggered` - -**Files:** -- Modify: `nav/conditional.go` -- Modify: `nav/conditional_test.go` - -- [ ] **Step 1: Write the failing test** - -Append to `nav/conditional_test.go`: - -```go -func TestConditionalTriggered(t *testing.T) { - cases := []struct { - name string - kind ConditionalKind - trigger float32 - altitude float32 - rate float32 // vertical rate (positive = climb) - want bool - }{ - // --- ConditionalLeaving --- - {"LV climbing well past", ConditionalLeaving, 3000, 3200, +500, true}, - {"LV descending well past", ConditionalLeaving, 3000, 2800, -500, true}, - {"LV level at trigger", ConditionalLeaving, 3000, 3000, 0, false}, - {"LV within tolerance climbing", ConditionalLeaving, 3000, 3020, +500, false}, // <50ft past - {"LV 60ft past climbing", ConditionalLeaving, 3000, 3060, +500, true}, - {"LV 60ft below climbing (wrong dir)", ConditionalLeaving, 3000, 2940, +500, false}, - // --- ConditionalReaching --- - {"RC within 100ft", ConditionalReaching, 10000, 9950, +500, true}, - {"RC 50ft past still climbing", ConditionalReaching, 10000, 10050, +500, true}, - {"RC 200ft short climbing", ConditionalReaching, 10000, 9800, +500, false}, - {"RC leveled at target", ConditionalReaching, 10000, 10000, 0, true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - n := makeTestNav(t, 180) - n.FlightState.Altitude = tc.altitude - n.FlightState.AltitudeRate = tc.rate - pc := &PendingConditionalCommand{Kind: tc.kind, Altitude: tc.trigger} - if got := conditionalTriggered(&n, pc); got != tc.want { - t.Errorf("want %v got %v (kind=%v trigger=%v alt=%v rate=%v)", - tc.want, got, tc.kind, tc.trigger, tc.altitude, tc.rate) - } - }) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./nav/... -run TestConditionalTriggered -v` -Expected: FAIL — undefined `conditionalTriggered`. - -- [ ] **Step 3: Implement `conditionalTriggered`** - -Append to `nav/conditional.go`: - -```go -import "github.com/mmp/vice/math" // merge into existing imports - -// conditionalTriggered reports whether the pending conditional command -// should fire given the aircraft's current vertical state. -// -// ConditionalLeaving: fires when altitude is >50 ft past trigger in the -// direction of current vertical motion. -// ConditionalReaching: fires when altitude is within 100 ft of trigger. -func conditionalTriggered(nav *Nav, pc *PendingConditionalCommand) bool { - alt := nav.FlightState.Altitude - diff := alt - pc.Altitude - switch pc.Kind { - case ConditionalLeaving: - const leavingTol = 50.0 - if math.Abs(diff) <= leavingTol { - return false - } - rate := nav.FlightState.AltitudeRate - // Same-sign check: diff>0 (above trigger) requires rate>0 (climbing), - // diff<0 (below) requires rate<0 (descending). Zero rate with altitude - // drift outside tolerance (unusual but possible) is not a trigger. - return (diff > 0 && rate > 0) || (diff < 0 && rate < 0) - case ConditionalReaching: - const reachingTol = 100.0 - return math.Abs(diff) <= reachingTol - } - return false -} -``` - -Verify `nav.FlightState.AltitudeRate` exists: `grep -n "AltitudeRate" nav/nav.go`. If the field name differs (e.g., `VerticalRate`, `ClimbRate`), adjust. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./nav/... -run TestConditionalTriggered -v` -Expected: PASS (all subtests). - -- [ ] **Step 5: Commit** - -```bash -git add nav/conditional.go nav/conditional_test.go -git commit -m "nav: add conditionalTriggered predicate" -``` - ---- - -### Task 6: `triggerReachable` reachability check in sim - -**Files:** -- Modify: `sim/control.go` (add new helper function near other private helpers) -- Modify: `sim/control_test.go` - -- [ ] **Step 1: Write the failing test** - -Add to `sim/control_test.go` (append to the existing file — find a section with unit tests that don't need a full sim, or add a new one): - -```go -func TestTriggerReachable(t *testing.T) { - cases := []struct { - name string - kind nav.ConditionalKind - trigger float32 - current float32 - assigned *float32 - want bool - }{ - // LV: within 500ft slack even if direction is wrong - {"LV aircraft at 3050 climbing past", nav.ConditionalLeaving, 3000, 3050, floatp(5000), true}, - {"LV aircraft far past", nav.ConditionalLeaving, 3000, 5000, floatp(7000), false}, - {"LV trigger in path", nav.ConditionalLeaving, 3000, 1000, floatp(5000), true}, - {"LV no target, far from trigger", nav.ConditionalLeaving, 3000, 8000, nil, false}, - // RC: trigger must be between current and assigned target - {"RC target is trigger", nav.ConditionalReaching, 10000, 5000, floatp(10000), true}, - {"RC trigger above target", nav.ConditionalReaching, 12000, 5000, floatp(10000), false}, - {"RC no target but close", nav.ConditionalReaching, 10000, 9900, nil, true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - ac := &Aircraft{} // minimal; set up FlightState and Nav.Altitude - ac.Nav.FlightState.Altitude = tc.current - ac.Nav.Altitude.Assigned = tc.assigned - got := triggerReachable(ac, tc.kind, tc.trigger) - if got != tc.want { - t.Errorf("want %v got %v", tc.want, got) - } - }) - } -} - -func floatp(v float32) *float32 { return &v } -``` - -If a helper `floatp` (or similar) already exists in the test file, reuse it. Search: `grep -n "func floatp\|func fptr" sim/*_test.go`. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./sim/... -run TestTriggerReachable -v` -Expected: FAIL — undefined `triggerReachable`. - -- [ ] **Step 3: Implement `triggerReachable`** - -Add to `sim/control.go` near the other conditional-command helpers (e.g., just above or below `parseSpeedUntil`): - -```go -// triggerReachable reports whether a LV/RC trigger altitude is -// reasonably reachable from the aircraft's current vertical state, -// allowing the controller command to be accepted. -// -// For ConditionalLeaving: accepted if the aircraft is within 500 ft of -// the trigger (so "leaving 3,000" works even for an aircraft at 3,050), -// or if the trigger lies between current altitude and assigned target. -// -// For ConditionalReaching: accepted if the trigger lies between current -// altitude and assigned target, or (if no target assigned) the aircraft -// is within 500 ft of the trigger. -func triggerReachable(ac *Aircraft, kind nav.ConditionalKind, trigger float32) bool { - cur := ac.Nav.FlightState.Altitude - target := ac.Nav.Altitude.Assigned - diff := math.Abs(cur - trigger) - switch kind { - case nav.ConditionalLeaving: - if diff <= 500 { - return true - } - if target == nil { - return false - } - return betweenAlt(trigger, cur, *target) - case nav.ConditionalReaching: - if target == nil { - return diff <= 500 - } - return betweenAlt(trigger, cur, *target) - } - return false -} - -// betweenAlt reports whether v lies between a and b (inclusive), in -// either ordering. -func betweenAlt(v, a, b float32) bool { - lo, hi := a, b - if lo > hi { - lo, hi = hi, lo - } - return v >= lo && v <= hi -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./sim/... -run TestTriggerReachable -v` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add sim/control.go sim/control_test.go -git commit -m "sim: add triggerReachable helper for LV/RC conditional commands" -``` - ---- - -### Task 7: `parseConditionalAltitude` - -**Files:** -- Modify: `sim/control.go` -- Modify: `sim/control_test.go` - -- [ ] **Step 1: Write the failing test** - -Append to `sim/control_test.go`: - -```go -func TestParseConditionalAltitude(t *testing.T) { - cases := []struct { - in string - want float32 - wantErr bool - }{ - {"30", 3000, false}, // hundreds-of-feet - {"130", 13000, false}, - {"100", 10000, false}, - {"1000", 1000, false}, // >600 && %100==0 → already feet - {"13000", 13000, false}, // ditto - {"", 0, true}, - {"abc", 0, true}, - } - for _, tc := range cases { - got, err := parseConditionalAltitude(tc.in) - if (err != nil) != tc.wantErr { - t.Errorf("parseConditionalAltitude(%q) err=%v wantErr=%v", tc.in, err, tc.wantErr) - continue - } - if !tc.wantErr && got != tc.want { - t.Errorf("parseConditionalAltitude(%q) = %v, want %v", tc.in, got, tc.want) - } - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./sim/... -run TestParseConditionalAltitude -v` -Expected: FAIL — undefined. - -- [ ] **Step 3: Implement `parseConditionalAltitude`** - -Add to `sim/control.go` near `triggerReachable`: - -```go -// parseConditionalAltitude parses the altitude-encoding convention used -// by LV/RC (and RR) commands: number × 100, with a carve-out for values -// that look like feet already (>600 and evenly divisible by 100). -func parseConditionalAltitude(s string) (float32, error) { - if s == "" { - return 0, ErrInvalidCommandSyntax - } - n, err := strconv.Atoi(s) - if err != nil { - return 0, err - } - if n > 600 && n%100 == 0 { - return float32(n), nil - } - return float32(n * 100), nil -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./sim/... -run TestParseConditionalAltitude -v` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add sim/control.go sim/control_test.go -git commit -m "sim: add parseConditionalAltitude helper" -``` - ---- - -### Task 8: `parseConditionalAction` inner-command parser - -**Files:** -- Modify: `sim/control.go` -- Modify: `sim/control_test.go` - -- [ ] **Step 1: Write the failing test** - -Append to `sim/control_test.go`: - -```go -func TestParseConditionalAction(t *testing.T) { - cases := []struct { - in string - wantType string // type name of returned ConditionalAction - wantProps map[string]any - wantErr bool - }{ - {"H010", "ConditionalHeading", map[string]any{"Heading": 10, "Turn": av.TurnClosest}, false}, - {"L100", "ConditionalHeading", map[string]any{"Heading": 100, "Turn": av.TurnLeft}, false}, - {"R100", "ConditionalHeading", map[string]any{"Heading": 100, "Turn": av.TurnRight}, false}, - {"L20D", "ConditionalHeading", map[string]any{"ByDegrees": 20, "Turn": av.TurnLeft}, false}, - {"R30D", "ConditionalHeading", map[string]any{"ByDegrees": 30, "Turn": av.TurnRight}, false}, - {"DAAC", "ConditionalDirectFix", map[string]any{"Fix": "AAC", "Turn": av.TurnClosest}, false}, - {"LDAAC", "ConditionalDirectFix", map[string]any{"Fix": "AAC", "Turn": av.TurnLeft}, false}, - {"RDAAC", "ConditionalDirectFix", map[string]any{"Fix": "AAC", "Turn": av.TurnRight}, false}, - {"S210", "ConditionalSpeed", nil, false}, - {"M78", "ConditionalMach", map[string]any{"Mach": float32(0.78)}, false}, - - // Rejections: altitude-changing inners, unknowns, malformed - {"C50", "", nil, true}, - {"CVS", "", nil, true}, - {"DVS", "", nil, true}, - {"X010", "", nil, true}, - {"", "", nil, true}, - {"H", "", nil, true}, - {"HXYZ", "", nil, true}, - } - for _, tc := range cases { - t.Run(tc.in, func(t *testing.T) { - got, err := parseConditionalAction(tc.in) - if (err != nil) != tc.wantErr { - t.Fatalf("parseConditionalAction(%q) err=%v wantErr=%v", tc.in, err, tc.wantErr) - } - if tc.wantErr { - return - } - typeName := reflect.TypeOf(got).Name() - if typeName != tc.wantType { - t.Fatalf("parseConditionalAction(%q) type = %s, want %s", tc.in, typeName, tc.wantType) - } - // Property check via reflection - v := reflect.ValueOf(got) - for k, want := range tc.wantProps { - field := v.FieldByName(k) - if !field.IsValid() { - t.Errorf("no field %s on %s", k, typeName) - continue - } - if !reflect.DeepEqual(field.Interface(), want) { - t.Errorf("%s.%s = %v, want %v", typeName, k, field.Interface(), want) - } - } - }) - } -} -``` - -Add `reflect` to the test file's imports if not already present. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./sim/... -run TestParseConditionalAction -v` -Expected: FAIL — undefined. - -- [ ] **Step 3: Implement `parseConditionalAction`** - -Add to `sim/control.go` near the other conditional helpers: - -```go -// parseConditionalAction parses an inner command string (the right-hand -// side of LV/RC) into a typed ConditionalAction. Accepts only lateral and -// speed/mach actions; altitude-changing and unknown inners return -// ErrInvalidCommandSyntax. -// -// Grammar: -// H{hdg} → ConditionalHeading (closest turn) -// L{hdg} | R{hdg} → ConditionalHeading (left/right turn to heading) -// L{deg}D | R{deg}D → ConditionalHeading (turn N degrees) -// D{fix} → ConditionalDirectFix (closest) -// LD{fix} | RD{fix} → ConditionalDirectFix (left/right) -// S{spd} → ConditionalSpeed -// M{mach} → ConditionalMach (2-digit mach, e.g. M78 → 0.78) -func parseConditionalAction(s string) (nav.ConditionalAction, error) { - if len(s) < 2 { - return nil, ErrInvalidCommandSyntax - } - switch s[0] { - case 'H': - hdg, err := strconv.Atoi(s[1:]) - if err != nil { - return nil, ErrInvalidCommandSyntax - } - return nav.ConditionalHeading{Heading: hdg, Turn: av.TurnClosest}, nil - - case 'L', 'R': - turn := av.TurnLeft - if s[0] == 'R' { - turn = av.TurnRight - } - // LD{fix} / RD{fix} - if len(s) >= 5 && s[1] == 'D' { - return nav.ConditionalDirectFix{Fix: strings.ToUpper(s[2:]), Turn: turn}, nil - } - // LnnD / RnnD - if l := len(s); l > 2 && s[l-1] == 'D' { - deg, err := strconv.Atoi(s[1 : l-1]) - if err != nil { - return nil, ErrInvalidCommandSyntax - } - return nav.ConditionalHeading{ByDegrees: deg, Turn: turn}, nil - } - // L{hdg} / R{hdg} - hdg, err := strconv.Atoi(s[1:]) - if err != nil { - return nil, ErrInvalidCommandSyntax - } - return nav.ConditionalHeading{Heading: hdg, Turn: turn}, nil - - case 'D': - if len(s) < 4 { - return nil, ErrInvalidCommandSyntax - } - return nav.ConditionalDirectFix{Fix: strings.ToUpper(s[1:]), Turn: av.TurnClosest}, nil - - case 'S': - sr, err := av.ParseSpeedRestriction(s[1:]) - if err != nil { - return nil, ErrInvalidCommandSyntax - } - return nav.ConditionalSpeed{Restriction: *sr}, nil - - case 'M': - if len(s) != 3 { - return nil, ErrInvalidCommandSyntax - } - mach, err := strconv.ParseFloat(s[1:], 32) - if err != nil { - return nil, ErrInvalidCommandSyntax - } - return nav.ConditionalMach{Mach: float32(mach) / 100.0}, nil - } - return nil, ErrInvalidCommandSyntax -} -``` - -Verify `av.ParseSpeedRestriction` exists and returns `*av.SpeedRestriction`: `grep -n "func ParseSpeedRestriction" aviation/*.go`. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./sim/... -run TestParseConditionalAction -v` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add sim/control.go sim/control_test.go -git commit -m "sim: add parseConditionalAction for LV/RC inner commands" -``` - ---- - -### Task 9: `ConditionalCommandIntent` in aviation - -**Files:** -- Modify: `aviation/intent.go` -- Create or modify: `aviation/intent_test.go` (if a test file already exists for intents, use it) - -- [ ] **Step 1: Write the failing test** - -Find or create a test file for intent rendering. Search: `ls aviation/*_test.go`. If an existing test file covers intents (e.g., `intent_test.go`), append there; otherwise create `aviation/intent_test.go`. - -```go -// aviation/intent_test.go (append or create) -func TestConditionalCommandIntentRender(t *testing.T) { - // Use a simple stub action for testing the intent wrapper. - stub := stubConditionalAction{text: "fly heading 010"} - cases := []struct { - name string - kind ConditionalKind - alt float32 - want []string // substrings expected in the rendered output - }{ - {"leaving", ConditionalLeaving, 3000, []string{"leaving", "3", "fly heading 010"}}, - {"reaching", ConditionalReaching, 10000, []string{"reaching", "10", "fly heading 010"}}, - } - r := rand.New(rand.NewPCG(1, 2)) - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - intent := ConditionalCommandIntent{Kind: tc.kind, Altitude: tc.alt, Action: stub} - rt := &RadioTransmission{} - intent.Render(rt, r) - written := strings.ToLower(rt.Written(r)) - for _, w := range tc.want { - if !strings.Contains(written, strings.ToLower(w)) { - t.Errorf("Render missing %q in %q", w, written) - } - } - }) - } -} - -type stubConditionalAction struct{ text string } - -func (s stubConditionalAction) Render(rt *RadioTransmission, r *rand.Rand) { - rt.Add(s.text) -} -// Execute not needed for this test — but if the interface requires it, -// make stub satisfy the full ConditionalAction interface. Note that -// aviation must not import nav (cycle risk); if ConditionalAction is -// defined in nav, the intent must reference the interface via a -// package-neutral declaration — see Step 3. -``` - -Note the import-cycle concern — `aviation` is a lower-level package than `nav` and cannot import from it. This means `ConditionalCommandIntent.Action` must NOT be typed as `nav.ConditionalAction`. Two options: - -(a) Declare a separate minimal interface in `aviation` — e.g., `type ConditionalActionRender interface { Render(*RadioTransmission, *rand.Rand) }`. The `nav.ConditionalAction` interface embeds both `Execute` and `Render`, so any `nav` action automatically satisfies the `aviation` render-only interface. Use this option. - -(b) Move the whole action type hierarchy down into `aviation` — more invasive; declines. - -The test's `stubConditionalAction` above implements only `Render`, which fits option (a). - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./aviation/... -run TestConditionalCommandIntentRender -v` -Expected: FAIL — undefined. - -- [ ] **Step 3: Implement `ConditionalCommandIntent`** - -Add to `aviation/intent.go`, near the other special intents (e.g., after `ContactTowerIntent`): - -```go -// ConditionalKind in aviation mirrors the nav-package enum for use by -// ConditionalCommandIntent. Values must match nav.ConditionalKind. -type ConditionalKind uint8 - -const ( - ConditionalLeaving ConditionalKind = iota - ConditionalReaching -) - -// ConditionalActionRender is the subset of nav.ConditionalAction that -// the aviation-layer readback needs. Defined here to avoid an import -// cycle (nav imports aviation, not the other way around). -type ConditionalActionRender interface { - Render(rt *RadioTransmission, r *rand.Rand) -} - -// ConditionalCommandIntent is the readback for a "leaving/reaching {alt}, -// do X" command. It composes with the inner action's own Render so -// phraseology for H/L/R/D/S/M stays consistent with non-conditional -// variants. -type ConditionalCommandIntent struct { - Kind ConditionalKind - Altitude float32 - Action ConditionalActionRender -} - -func (c ConditionalCommandIntent) Render(rt *RadioTransmission, r *rand.Rand) { - switch c.Kind { - case ConditionalLeaving: - rt.Add("[leaving|passing] {alt}, ", c.Altitude) - case ConditionalReaching: - rt.Add("[reaching|level at|on reaching] {alt}, ", c.Altitude) - } - if c.Action != nil { - c.Action.Render(rt, r) - } -} -``` - -In `nav/conditional.go`, change `ConditionalKind` and the constants to **alias** the aviation ones to guarantee the values match: - -```go -type ConditionalKind = av.ConditionalKind - -const ( - ConditionalLeaving = av.ConditionalLeaving - ConditionalReaching = av.ConditionalReaching -) -``` - -Remove the old `ConditionalKind`, `ConditionalLeaving`, `ConditionalReaching` declarations from `nav/conditional.go`. The type alias (`=`) makes `nav.ConditionalKind` the same type as `av.ConditionalKind`, so existing code using either spelling compiles. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./aviation/... -run TestConditionalCommandIntentRender -v` -Expected: PASS. - -Run: `go test ./nav/...` -Expected: PASS (all prior tests still pass with aliased enum). - -- [ ] **Step 5: Commit** - -```bash -git add aviation/intent.go aviation/intent_test.go nav/conditional.go -git commit -m "aviation: add ConditionalCommandIntent; nav: alias enum to aviation" -``` - ---- - -### Task 10: `AssignConditional` sim method - -**Files:** -- Modify: `sim/control.go` -- Modify: `sim/control_test.go` - -- [ ] **Step 1: Write the failing test** - -Append to `sim/control_test.go`. Use the existing sim-test builder (search for how other sim-level commands like `ReportReaching` are tested — look at `sim/control_test.go` for a `setupTestSim` or `newTestSim` helper, and the pattern for `TestReportReaching` or similar). If no such pattern exists, build a minimal one using `NewSim` or the in-file test harness. - -```go -func TestAssignConditionalInstallsSlot(t *testing.T) { - s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000 /*alt*/, 7000 /*assigned*/) - action := nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest} - intent, err := s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, 3000, action) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if intent == nil { - t.Fatalf("expected non-nil intent") - } - ac := s.lookupAircraft(callsign) // whatever the test helper is - if ac.Nav.PendingConditionalCommand == nil { - t.Fatalf("expected PendingConditionalCommand installed") - } - if ac.Nav.PendingConditionalCommand.Altitude != 3000 { - t.Fatalf("wrong altitude: %v", ac.Nav.PendingConditionalCommand.Altitude) - } -} - -func TestAssignConditionalRejectsUnreachable(t *testing.T) { - // Aircraft at 5000, no assigned altitude change; trigger 3000 → unreachable. - s, callsign, tcw := setupTestSimWithAircraftAt(t, 5000, 5000) - action := nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest} - _, err := s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, 3000, action) - if err == nil { - t.Fatalf("expected error for unreachable trigger, got nil") - } -} - -func TestAssignConditionalSupersedes(t *testing.T) { - s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) - first := nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest} - second := nav.ConditionalDirectFix{Fix: "AAC", Turn: av.TurnClosest} - _, _ = s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, 3000, first) - _, _ = s.AssignConditional(tcw, callsign, nav.ConditionalReaching, 6000, second) - ac := s.lookupAircraft(callsign) - pc := ac.Nav.PendingConditionalCommand - if pc == nil || pc.Kind != nav.ConditionalReaching || pc.Altitude != 6000 { - t.Fatalf("expected superseded slot: reaching 6000, got %+v", pc) - } -} -``` - -If the helpers `setupTestSimWithAircraftAt` and `lookupAircraft` don't exist, adapt to whatever the existing test file uses — look at the nearest `TestAssign*` function in `sim/control_test.go` for the pattern. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./sim/... -run TestAssignConditional -v` -Expected: FAIL — undefined method. - -- [ ] **Step 3: Implement `AssignConditional`** - -Add to `sim/control.go`, near `ReportReaching` (which is at roughly line 320 per commit `347d0085`): - -```go -// AssignConditional installs a deferred LV/RC action on the aircraft's -// nav state. The action fires silently when sim.updateState observes -// the altitude trigger. Returns an error if the trigger is not -// reachable from the aircraft's current vertical state. -func (s *Sim) AssignConditional(tcw TCW, callsign av.ADSBCallsign, - kind nav.ConditionalKind, altitude float32, action nav.ConditionalAction) (av.CommandIntent, error) { - - s.mu.Lock(s.lg) - defer s.mu.Unlock(s.lg) - - return s.dispatchControlledAircraftCommand(tcw, callsign, - func(tcw TCW, ac *Aircraft) av.CommandIntent { - if !triggerReachable(ac, kind, altitude) { - return av.MakeUnableIntent("unable. %s is out of our climb/descent path.", - av.FormatAltitude(altitude)) - } - ac.Nav.PendingConditionalCommand = &nav.PendingConditionalCommand{ - Kind: kind, - Altitude: altitude, - Action: action, - } - return av.ConditionalCommandIntent{ - Kind: kind, - Altitude: altitude, - Action: action, - } - }) -} -``` - -`av.FormatAltitude` should already exist (used throughout the codebase). `av.MakeUnableIntent` exists in aviation (used at `nav/commands.go:41`). - -Note: `dispatchControlledAircraftCommand` expects the callback to return a `CommandIntent`, not an error. Reachability rejection becomes an unable-intent here (same convention as "that altitude is above our ceiling" in nav/commands.go:41). The outer `(intent, error)` return of the method path is for lookup errors (no such aircraft), not logical "unable" cases. - -Review this decision against the Q4 design intent — "Reject with error." An unable-intent is how the rest of the sim signals unable; tests should assert on the intent type and message rather than `err != nil`. Adjust `TestAssignConditionalRejectsUnreachable` accordingly: - -```go -func TestAssignConditionalRejectsUnreachable(t *testing.T) { - s, callsign, tcw := setupTestSimWithAircraftAt(t, 5000, 5000) - action := nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest} - intent, err := s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, 3000, action) - if err != nil { - t.Fatalf("dispatch error: %v", err) - } - if _, ok := intent.(av.UnableIntent); !ok { - t.Fatalf("expected UnableIntent for unreachable trigger, got %T", intent) - } - ac := s.lookupAircraft(callsign) - if ac.Nav.PendingConditionalCommand != nil { - t.Fatalf("expected no slot installed for unable") - } -} -``` - -Check the actual unable-intent type name: `grep -n "type UnableIntent\|type.*Unable" aviation/*.go`. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./sim/... -run TestAssignConditional -v` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add sim/control.go sim/control_test.go -git commit -m "sim: add AssignConditional method for LV/RC commands" -``` - ---- - -### Task 11: Dispatch branch for `LV` in case 'L' - -**Files:** -- Modify: `sim/control.go` -- Modify: `sim/control_test.go` - -- [ ] **Step 1: Write the failing test** - -Append to `sim/control_test.go`: - -```go -func TestRunControlCommandLV(t *testing.T) { - s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) - intent, err := s.runOneControlCommand(tcw, callsign, "LV30/H010", 0) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if _, ok := intent.(av.ConditionalCommandIntent); !ok { - t.Fatalf("expected ConditionalCommandIntent, got %T", intent) - } - ac := s.lookupAircraft(callsign) - if ac.Nav.PendingConditionalCommand == nil { - t.Fatalf("slot not installed") - } - if ac.Nav.PendingConditionalCommand.Altitude != 3000 { - t.Fatalf("wrong altitude %v", ac.Nav.PendingConditionalCommand.Altitude) - } -} - -func TestRunControlCommandLVRejectsMalformed(t *testing.T) { - s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) - cases := []string{ - "LV30H010", // missing slash - "LV/H010", // empty altitude - "LV30/", // empty inner - "LVABC/H010", // non-numeric altitude - "LV30/C50", // altitude-changing inner - "LV30/X010", // unknown inner - } - for _, cmd := range cases { - t.Run(cmd, func(t *testing.T) { - _, err := s.runOneControlCommand(tcw, callsign, cmd, 0) - if err == nil { - t.Fatalf("expected error for %q, got nil", cmd) - } - }) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./sim/... -run TestRunControlCommandLV -v` -Expected: FAIL (LV command not yet handled — falls through to existing heading parse, which will fail weirdly). - -- [ ] **Step 3: Add the LV branch in case 'L'** - -In `sim/control.go`, in `runOneControlCommand`, locate `case 'L':` (around line 4096 per the pre-branch state; confirm with `grep -n "case 'L':" sim/control.go`). Insert the following branch BEFORE any existing branches in `case 'L'`: - -```go -case 'L': - if strings.HasPrefix(command, "LV") && len(command) > 2 { - altStr, inner, ok := strings.Cut(command[2:], "/") - if !ok || altStr == "" || inner == "" { - return nil, ErrInvalidCommandSyntax - } - alt, err := parseConditionalAltitude(altStr) - if err != nil { - return nil, err - } - action, err := parseConditionalAction(inner) - if err != nil { - return nil, err - } - return s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, alt, action) - } - // ...existing case 'L' body unchanged... -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./sim/... -run TestRunControlCommandLV -v` -Expected: PASS. - -Run the full sim suite to ensure no regression: `go test ./sim/...` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add sim/control.go sim/control_test.go -git commit -m "sim: dispatch LV{alt}/{inner} as conditional-leaving command" -``` - ---- - -### Task 12: Dispatch branch for `RC` in case 'R' - -**Files:** -- Modify: `sim/control.go` -- Modify: `sim/control_test.go` - -- [ ] **Step 1: Write the failing test** - -Append to `sim/control_test.go`: - -```go -func TestRunControlCommandRC(t *testing.T) { - s, callsign, tcw := setupTestSimWithAircraftAt(t, 5000, 10000) - intent, err := s.runOneControlCommand(tcw, callsign, "RC100/DAAC", 0) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if _, ok := intent.(av.ConditionalCommandIntent); !ok { - t.Fatalf("expected ConditionalCommandIntent, got %T", intent) - } - ac := s.lookupAircraft(callsign) - if ac.Nav.PendingConditionalCommand == nil { - t.Fatalf("slot not installed") - } - if ac.Nav.PendingConditionalCommand.Altitude != 10000 { - t.Fatalf("wrong altitude %v", ac.Nav.PendingConditionalCommand.Altitude) - } -} - -func TestRunControlCommandRCDoesNotConflictWithRR(t *testing.T) { - // Ensure RC100 is not parsed as RR (report reaching). - s, callsign, tcw := setupTestSimWithAircraftAt(t, 5000, 10000) - intent, err := s.runOneControlCommand(tcw, callsign, "RC100/H010", 0) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if _, ok := intent.(av.ConditionalCommandIntent); !ok { - t.Fatalf("expected ConditionalCommandIntent, got %T", intent) - } - ac := s.lookupAircraft(callsign) - // RR would have set ReportReachingAltitude, not PendingConditionalCommand. - if ac.Nav.ReportReachingAltitude != nil { - t.Fatalf("RR altitude should not be set, got %v", *ac.Nav.ReportReachingAltitude) - } - if ac.Nav.PendingConditionalCommand == nil { - t.Fatalf("conditional slot not installed") - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./sim/... -run TestRunControlCommandRC -v` -Expected: FAIL. - -- [ ] **Step 3: Add the RC branch in case 'R'** - -In `sim/control.go`, in `runOneControlCommand`, locate `case 'R':` (around line 4139 in the pre-branch state; confirm with `grep -n "case 'R':" sim/control.go`). Insert a new branch BEFORE the existing `RR` branch: - -```go -case 'R': - if command == "RON" { - return s.ResumeOwnNavigation(tcw, callsign) - } else if command == "RST" { - return s.RadarServicesTerminated(tcw, callsign) - } else if strings.HasPrefix(command, "RC") && len(command) > 2 && strings.Contains(command, "/") { - altStr, inner, ok := strings.Cut(command[2:], "/") - if !ok || altStr == "" || inner == "" { - return nil, ErrInvalidCommandSyntax - } - alt, err := parseConditionalAltitude(altStr) - if err != nil { - return nil, err - } - action, err := parseConditionalAction(inner) - if err != nil { - return nil, err - } - return s.AssignConditional(tcw, callsign, nav.ConditionalReaching, alt, action) - } else if strings.HasPrefix(command, "RR") && len(command) > 2 && util.IsAllNumbers(command[2:]) { - // ...existing RR branch body unchanged... - } - // ...remainder of case 'R' unchanged... -``` - -The `strings.Contains(command, "/")` guard on RC disambiguates from any future `RC` pattern; the slash is mandatory for the conditional syntax. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./sim/... -run TestRunControlCommandRC -v` -Expected: PASS. - -Run: `go test ./sim/...` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add sim/control.go sim/control_test.go -git commit -m "sim: dispatch RC{alt}/{inner} as conditional-reaching command" -``` - ---- - -### Task 13: Trigger firing in `sim.updateState` - -**Files:** -- Modify: `sim/sim.go` -- Modify: `sim/e2e_test.go` (or create `sim/conditional_e2e_test.go`) - -- [ ] **Step 1: Write the failing test** - -Create `sim/conditional_e2e_test.go`. Model after the existing e2e tests in `sim/e2e_test.go` — read that file for the pattern (sim setup, tick loop, assertions). - -```go -package sim - -import ( - "testing" - - av "github.com/mmp/vice/aviation" - "github.com/mmp/vice/nav" -) - -func TestLVHeadingE2E(t *testing.T) { - s, callsign, tcw := setupE2ESimClimbing(t, 2000 /*from*/, 7000 /*to*/) - // Leaving 3,000 → turn left 010 - _, err := s.runOneControlCommand(tcw, callsign, "LV30/L010", 0) - if err != nil { - t.Fatalf("command error: %v", err) - } - // Tick until aircraft is >50 ft past 3,000 climbing. - s.tickUntil(t, func(ac *Aircraft) bool { - return ac.Nav.FlightState.Altitude > 3100 - }, 300 /*tick budget*/) - - ac := s.lookupAircraft(callsign) - if ac.Nav.PendingConditionalCommand != nil { - t.Fatalf("slot not cleared after trigger fire") - } - if hdg, ok := ac.Nav.AssignedHeading(); !ok || hdg != 10 { - t.Fatalf("expected assigned heading 10, got ok=%v hdg=%v", ok, hdg) - } -} - -func TestRCDirectFixE2E(t *testing.T) { - s, callsign, tcw := setupE2ESimClimbingWithFix(t, 7000, 10000, "AAC") - _, err := s.runOneControlCommand(tcw, callsign, "RC100/DAAC", 0) - if err != nil { - t.Fatalf("command error: %v", err) - } - s.tickUntil(t, func(ac *Aircraft) bool { - return ac.Nav.FlightState.Altitude >= 9900 - }, 600) - ac := s.lookupAircraft(callsign) - if ac.Nav.PendingConditionalCommand != nil { - t.Fatalf("slot not cleared") - } - if len(ac.Nav.Waypoints) == 0 || ac.Nav.Waypoints[0].Fix != "AAC" { - t.Fatalf("expected direct AAC, got waypoints %+v", ac.Nav.Waypoints) - } -} -``` - -If helpers like `setupE2ESimClimbing`, `tickUntil`, `lookupAircraft` don't exist, create them (likely small wrappers around existing test utilities). Look at `sim/altimeter_integration_test.go` which was added for a similar end-to-end verification — it's the closest precedent. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./sim/... -run "TestLVHeadingE2E|TestRCDirectFixE2E" -v` -Expected: FAIL — the command installs the slot, but no trigger-firing code exists yet, so the slot stays and heading/waypoint isn't updated. - -- [ ] **Step 3: Add trigger-firing code to `sim.updateState`** - -In `sim/sim.go`, in `Sim.updateState`, near the existing `ReportReachingAltitude` check (added in commit `347d0085`), insert: - -```go -// "Leaving/reaching {alt}, do X" — when the aircraft crosses the -// trigger altitude, silently execute the deferred action. The slot -// is cleared BEFORE Execute runs so a mis-parsed inner command that -// installs another conditional doesn't loop. -if pc := ac.Nav.PendingConditionalCommand; pc != nil && ac.IsAssociated() { - if nav.ConditionalTriggered(&ac.Nav, pc) { - action := pc.Action - ac.Nav.PendingConditionalCommand = nil - action.Execute(&ac.Nav, s.State.SimTime) - } -} -``` - -Note: `conditionalTriggered` from Task 5 was private (lowercase). Export it as `ConditionalTriggered` (capitalize the first letter in `nav/conditional.go`) so `sim` can call it. Update the Task 5 test to use the uppercase name. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./sim/... -run "TestLVHeadingE2E|TestRCDirectFixE2E" -v` -Expected: PASS. - -Run all tests: `go test ./sim/... ./nav/... ./aviation/... ./stt/...` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add sim/sim.go sim/conditional_e2e_test.go nav/conditional.go nav/conditional_test.go -git commit -m "sim: fire LV/RC conditional commands when altitude trigger is met" -``` - ---- - -### Task 14: STT grammar for `LV` trigger - -**Files:** -- Modify: `stt/handlers.go` -- Modify: `stt/handlers_test.go` - -- [ ] **Step 1: Read the STT framework first** - -Run: `grep -n "func registerSTTCommand\|type CommandOption" stt/registry.go` — get the exact signature. - -Examine an existing multi-slot command for the argument-binding pattern (e.g., how the `report reaching {altitude}` handler at commit `347d0085` binds `alt` to the handler parameter). Also examine a command that includes a turn direction (e.g., "turn left heading {hdg}") for how multi-token matches produce the command string. - -- [ ] **Step 2: Write the failing test** - -Append to `stt/handlers_test.go`. Look at existing tests (e.g., for "report reaching {altitude}") for the assertion pattern — probably `parseAndMatch(input)` returns the command string. - -```go -func TestSTTLeavingPatterns(t *testing.T) { - cases := []struct { - spoken string - want string // expected command string - }{ - {"leaving three thousand fly heading zero one zero", "LV30/H010"}, - {"passing one three thousand right heading one zero zero", "LV130/R100"}, - {"leaving five thousand turn left heading two seven zero", "LV50/L270"}, - {"leaving three thousand turn left twenty degrees", "LV30/L20D"}, - {"leaving three thousand direct alpha alpha charlie", "LV30/DAAC"}, - {"leaving five thousand reduce speed to two one zero", "LV50/S210"}, - } - for _, tc := range cases { - t.Run(tc.spoken, func(t *testing.T) { - got := matchSTT(tc.spoken) // existing test helper (or equivalent) - if got != tc.want { - t.Errorf("matchSTT(%q) = %q, want %q", tc.spoken, got, tc.want) - } - }) - } -} -``` - -If there's no existing `matchSTT` helper, search `stt/*_test.go` for how other tests exercise the grammar — the API is probably a method on a registry object. - -- [ ] **Step 3: Run test to verify it fails** - -Run: `go test ./stt/... -run TestSTTLeavingPatterns -v` -Expected: FAIL — no matching grammar registered. - -- [ ] **Step 4: Register the LV grammar** - -In `stt/handlers.go`, inside the existing `registerAllCommands` function, add a section for conditional-leaving patterns. Because the inner-command grammar is already defined elsewhere, we register one `registerSTTCommand` per (trigger × inner) combination. Keep each inner's phraseology consistent with the non-conditional version of the same command: - -```go -// "Leaving/passing {alt}, {inner}" — conditional LV commands. - -// LV/H{hdg}: "leaving three thousand, fly heading 010" -registerSTTCommand( - "leaving|passing {altitude}, fly heading {heading}", - func(alt int, hdg int) string { return fmt.Sprintf("LV%d/H%03d", alt, hdg) }, - WithName("conditional_lv_heading"), - WithPriority(11), -) - -// LV/L{hdg} and LV/R{hdg} -registerSTTCommand( - "leaving|passing {altitude}, turn left heading {heading}", - func(alt int, hdg int) string { return fmt.Sprintf("LV%d/L%03d", alt, hdg) }, - WithName("conditional_lv_turn_left_heading"), - WithPriority(11), -) -registerSTTCommand( - "leaving|passing {altitude}, turn right heading {heading}", - func(alt int, hdg int) string { return fmt.Sprintf("LV%d/R%03d", alt, hdg) }, - WithName("conditional_lv_turn_right_heading"), - WithPriority(11), -) - -// LV/L{deg}D and LV/R{deg}D -registerSTTCommand( - "leaving|passing {altitude}, turn left {num:1-180} degrees", - func(alt int, deg int) string { return fmt.Sprintf("LV%d/L%dD", alt, deg) }, - WithName("conditional_lv_turn_left_degrees"), - WithPriority(11), -) -registerSTTCommand( - "leaving|passing {altitude}, turn right {num:1-180} degrees", - func(alt int, deg int) string { return fmt.Sprintf("LV%d/R%dD", alt, deg) }, - WithName("conditional_lv_turn_right_degrees"), - WithPriority(11), -) - -// LV/D{fix}, LV/LD{fix}, LV/RD{fix} -registerSTTCommand( - "leaving|passing {altitude}, [proceed] direct {fix}", - func(alt int, fix string) string { return fmt.Sprintf("LV%d/D%s", alt, fix) }, - WithName("conditional_lv_direct"), - WithPriority(11), -) -registerSTTCommand( - "leaving|passing {altitude}, turn left direct {fix}", - func(alt int, fix string) string { return fmt.Sprintf("LV%d/LD%s", alt, fix) }, - WithName("conditional_lv_left_direct"), - WithPriority(11), -) -registerSTTCommand( - "leaving|passing {altitude}, turn right direct {fix}", - func(alt int, fix string) string { return fmt.Sprintf("LV%d/RD%s", alt, fix) }, - WithName("conditional_lv_right_direct"), - WithPriority(11), -) - -// LV/S{spd} -registerSTTCommand( - "leaving|passing {altitude}, [reduce speed to|maintain|slow to] {speed}", - func(alt int, spd int) string { return fmt.Sprintf("LV%d/S%d", alt, spd) }, - WithName("conditional_lv_speed"), - WithPriority(11), -) - -// LV/M{mach} -registerSTTCommand( - "leaving|passing {altitude}, [maintain] mach {num:50-99}", - func(alt int, mach int) string { return fmt.Sprintf("LV%d/M%d", alt, mach) }, - WithName("conditional_lv_mach"), - WithPriority(11), -) -``` - -Verify the template syntax matches the existing STT framework — read `stt/handlers.go` around the commit `347d0085` additions for precedent. The brackets `[...]` for optional tokens, pipes `|` for alternation, and `{name:range}` for constrained numbers are all inferred from the `stop_altitude_squawk_with_delta` command added in that commit. If the framework's token syntax differs, adjust. - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `go test ./stt/... -run TestSTTLeavingPatterns -v` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add stt/handlers.go stt/handlers_test.go -git commit -m "stt: add LV conditional-leaving voice patterns" -``` - ---- - -### Task 15: STT grammar for `RC` trigger - -**Files:** -- Modify: `stt/handlers.go` -- Modify: `stt/handlers_test.go` - -- [ ] **Step 1: Write the failing test** - -Append to `stt/handlers_test.go`: - -```go -func TestSTTReachingPatterns(t *testing.T) { - cases := []struct { - spoken string - want string - }{ - {"reaching one zero thousand fly heading zero one zero", "RC100/H010"}, - {"level at one zero thousand direct alpha alpha charlie", "RC100/DAAC"}, - {"on reaching five thousand reduce speed to two one zero", "RC50/S210"}, - {"reaching three five zero mach seven eight", "RC350/M78"}, - } - for _, tc := range cases { - t.Run(tc.spoken, func(t *testing.T) { - got := matchSTT(tc.spoken) - if got != tc.want { - t.Errorf("matchSTT(%q) = %q, want %q", tc.spoken, got, tc.want) - } - }) - } -} - -func TestSTTReachingDoesNotMatchReportReaching(t *testing.T) { - // "report reaching {alt}" must still route to the RR command, not RC. - got := matchSTT("report reaching one zero thousand") - if got != "RR100" { - t.Errorf("expected RR100 for report reaching, got %q", got) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./stt/... -run TestSTTReaching -v` -Expected: FAIL. - -- [ ] **Step 3: Register the RC grammar** - -In `stt/handlers.go`, add a parallel section to Task 14 but with the reaching triggers. Key differences: - -- Triggers: `"reaching|level at|on reaching {altitude}"`. -- **Avoid `"report reaching"` overlap**: the existing `report_reaching` handler at commit `347d0085` has priority 10. Set RC handlers to priority 11 (higher priority, but `report reaching` explicitly starts with `report`, so the prefix difference should be disambiguating). Add a test (already in Step 1) confirming the existing `report reaching` grammar still wins for its phrasing. - -```go -// "Reaching/level at/on reaching {alt}, {inner}" — conditional RC commands. - -registerSTTCommand( - "reaching|level at|on reaching {altitude}, fly heading {heading}", - func(alt int, hdg int) string { return fmt.Sprintf("RC%d/H%03d", alt, hdg) }, - WithName("conditional_rc_heading"), - WithPriority(11), -) -registerSTTCommand( - "reaching|level at|on reaching {altitude}, turn left heading {heading}", - func(alt int, hdg int) string { return fmt.Sprintf("RC%d/L%03d", alt, hdg) }, - WithName("conditional_rc_turn_left_heading"), - WithPriority(11), -) -registerSTTCommand( - "reaching|level at|on reaching {altitude}, turn right heading {heading}", - func(alt int, hdg int) string { return fmt.Sprintf("RC%d/R%03d", alt, hdg) }, - WithName("conditional_rc_turn_right_heading"), - WithPriority(11), -) -registerSTTCommand( - "reaching|level at|on reaching {altitude}, turn left {num:1-180} degrees", - func(alt int, deg int) string { return fmt.Sprintf("RC%d/L%dD", alt, deg) }, - WithName("conditional_rc_turn_left_degrees"), - WithPriority(11), -) -registerSTTCommand( - "reaching|level at|on reaching {altitude}, turn right {num:1-180} degrees", - func(alt int, deg int) string { return fmt.Sprintf("RC%d/R%dD", alt, deg) }, - WithName("conditional_rc_turn_right_degrees"), - WithPriority(11), -) -registerSTTCommand( - "reaching|level at|on reaching {altitude}, [proceed] direct {fix}", - func(alt int, fix string) string { return fmt.Sprintf("RC%d/D%s", alt, fix) }, - WithName("conditional_rc_direct"), - WithPriority(11), -) -registerSTTCommand( - "reaching|level at|on reaching {altitude}, turn left direct {fix}", - func(alt int, fix string) string { return fmt.Sprintf("RC%d/LD%s", alt, fix) }, - WithName("conditional_rc_left_direct"), - WithPriority(11), -) -registerSTTCommand( - "reaching|level at|on reaching {altitude}, turn right direct {fix}", - func(alt int, fix string) string { return fmt.Sprintf("RC%d/RD%s", alt, fix) }, - WithName("conditional_rc_right_direct"), - WithPriority(11), -) -registerSTTCommand( - "reaching|level at|on reaching {altitude}, [reduce speed to|maintain|slow to] {speed}", - func(alt int, spd int) string { return fmt.Sprintf("RC%d/S%d", alt, spd) }, - WithName("conditional_rc_speed"), - WithPriority(11), -) -registerSTTCommand( - "reaching|level at|on reaching {altitude}, [maintain] mach {num:50-99}", - func(alt int, mach int) string { return fmt.Sprintf("RC%d/M%d", alt, mach) }, - WithName("conditional_rc_mach"), - WithPriority(11), -) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./stt/... -run "TestSTTReaching|TestSTTLeaving" -v` -Expected: PASS. - -Full test run: `go test ./...` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add stt/handlers.go stt/handlers_test.go -git commit -m "stt: add RC conditional-reaching voice patterns" -``` - ---- - -### Task 16: whatsnew.md entry - -**Files:** -- Modify: `whatsnew.md` - -- [ ] **Step 1: Read the existing whatsnew format** - -Run: `head -30 whatsnew.md` — see the format for recent entries (bullet list, terse, user-facing). - -- [ ] **Step 2: Add the entry** - -Add a single bullet near the top of `whatsnew.md` (or in the most-recent-changes section, whatever the existing convention is): - -```markdown -- Added "leaving/reaching {altitude}, {action}" controller commands. Examples: `LV30/H010` ("leaving 3,000, fly heading 010"), `RC100/DAAC` ("reaching 10,000, direct AAC"). Supported inner actions: headings, turns by degrees, direct-to-fix, speed, and mach. -``` - -- [ ] **Step 3: Commit** - -```bash -git add whatsnew.md -git commit -m "docs: whatsnew entry for LV/RC conditional commands" -``` - ---- - -### Task 17: Final full-suite verification - -- [ ] **Step 1: Run everything** - -```bash -go test ./... -``` - -Expected: PASS. - -- [ ] **Step 2: Spot-check the feature end-to-end in a running sim (optional but recommended)** - -If time permits, launch vice, spawn a scenario with a departure climbing out, and issue `LV30/H010` via keyboard. Verify the readback renders "leaving 3,000, fly heading 010" and that the heading turn happens silently once the aircraft climbs through 3,000. Repeat for `RC100/DAAC` with an arrival descending to 10,000. - -- [ ] **Step 3: No commit** — this is verification only. - ---- - -## Spec coverage self-check - -Walking the spec section by section: - -| Spec section | Covered by | -|---|---| -| Data model (`PendingConditionalCommand`, `ConditionalAction`) | Task 1 | -| `ConditionalHeading` | Task 2 | -| `ConditionalDirectFix` | Task 3 | -| `ConditionalSpeed` + `ConditionalMach` | Task 4 | -| Trigger predicate | Task 5 | -| Reachability rule | Task 6 | -| Altitude encoding | Task 7 | -| Inner-command parser | Task 8 | -| Readback intent | Task 9 | -| `AssignConditional` sim method | Task 10 | -| `LV` dispatch | Task 11 | -| `RC` dispatch | Task 12 | -| Silent firing at trigger | Task 13 | -| STT voice (LV) | Task 14 | -| STT voice (RC) | Task 15 | -| User-visible changelog | Task 16 | -| Final regression check | Task 17 | - -No spec items unaccounted for. - -## Open verification notes - -These are items I've flagged in the plan that require a small amount of framework archaeology at implementation time — they don't change the design but affect the exact code: - -1. **STT template syntax** (Task 14 step 4) — the `{num:N-M}` range syntax and `[optional]` token delimiter are inferred from the existing `stop_altitude_squawk_with_delta` handler. If the framework uses different syntax, adjust the templates at that step and add a smaller-scope test to verify. - -2. **Temperature access in `ConditionalMach.Execute`** (Task 4 step 3) — if `FlightState.Temperature` doesn't exist, extend `ConditionalAction.Execute` with a `temp av.Temperature` parameter and have sim.updateState look it up via the weather model before calling. - -3. **`directFix` internal helper** (Task 3 step 3) — if no lowercase internal helper exists on `Nav`, use the public `DirectFix` and discard the returned intent. - -4. **`UnableIntent` naming** (Task 10 step 3) — confirm the exact type name of the unable-intent used by `av.MakeUnableIntent`. diff --git a/docs/superpowers/specs/2026-04-20-leaving-reaching-commands-design.md b/docs/superpowers/specs/2026-04-20-leaving-reaching-commands-design.md deleted file mode 100644 index 29d0b66cc..000000000 --- a/docs/superpowers/specs/2026-04-20-leaving-reaching-commands-design.md +++ /dev/null @@ -1,322 +0,0 @@ -# Leaving/Reaching Altitude Conditional Commands - -**Issue:** [mmp/vice#438](https://github.com/mmp/vice/issues/438) -**Branch:** `leaving-reaching-commands` (branched from `upstream/master@65adaa0c`) -**Date:** 2026-04-20 - -## Summary - -Add controller-issued conditional commands that defer an action until the aircraft's altitude crosses a specified trigger: - -- `LV{alt}/{inner}` — "leaving {alt}, do {inner}". Example: `LV30/H010` → "leaving 3,000, fly heading 010". -- `RC{alt}/{inner}` — "reaching {alt}, do {inner}". Example: `RC100/DAAC` → "reaching 10,000, direct AAC". - -This is the controller-issued counterpart to issue #48 (which added the scenario-time, waypoint-gated version). The keyboard syntax follows the existing `A{fix}/{inner}` precedent. - -## Motivation - -Real-world ATC routinely uses phraseology like "leaving three thousand, turn left heading zero one zero" to chain a lateral maneuver to an altitude event. Vice has no way to express this today; controllers must watch the altitude themselves and issue the heading change manually. - -## Design decisions - -| Decision | Choice | -|---|---| -| Inner command set | Closed set of typed actions (H/L/R/LD/RD/D/S/M). Matches the `A{fix}/...` precedent. | -| `LV` trigger semantics | Fires once altitude is ≥50 ft past trigger in the direction of current vertical motion. Direction-agnostic — works whether climbing or descending through. | -| `RC` trigger semantics | Fires on first contact within 100 ft of target, regardless of vertical rate. | -| Invalid trigger at issue time | Reject with error. Trigger must be reachable (within a 500 ft band of current altitude, or lie between current altitude and assigned target). | -| Multiple pending slots | Single slot per aircraft. A new `LV`/`RC` replaces the prior one. | -| Readback | Full readback: "leaving three thousand, fly heading zero one zero". | -| Trigger firing | Silent execution. No radio transmission when the action fires. | -| STT voice support | Included v1. Voice grammar registers patterns for each trigger × supported inner combination. | -| Handoff / frequency change | Pending slot persists across handoffs. Matches `RR{alt}` behavior. | - -## Architecture - -### Data model - -One new field on `Nav`, a `ConditionalAction` interface with one concrete type per supported inner command, and a `ConditionalCommandIntent` for the readback. - -```go -// nav/nav.go -type ConditionalKind uint8 - -const ( - ConditionalLeaving ConditionalKind = iota - ConditionalReaching -) - -type ConditionalAction interface { - Execute(nav *Nav, simTime Time) - Render(rt *av.RadioTransmission, r *rand.Rand) -} - -type ConditionalHeading struct { - Heading int - Turn av.TurnMethod // TurnClosest/Left/Right - ByDegrees int // nonzero for LnnD / RnnD -} -type ConditionalDirectFix struct { - Fix string - Turn av.TurnMethod // TurnClosest / Left / Right -} -type ConditionalSpeed struct { Restriction av.SpeedRestriction } -type ConditionalMach struct { Mach float32 } - -type PendingConditionalCommand struct { - Kind ConditionalKind - Altitude float32 // feet MSL - Action ConditionalAction -} - -type Nav struct { - // ... existing fields ... - PendingConditionalCommand *PendingConditionalCommand -} -``` - -Slot is cleared when: -- Trigger fires (after the action executes). -- A new `LV`/`RC` command is issued (replaces). - -Slot is **not** cleared on new altitude assignment, heading change, speed change, approach clearance, handoff, or frequency change. - -### Supported inner commands - -| Token | Action type | Example | -|---|---|---| -| `H{hdg}` | `ConditionalHeading{Turn=TurnClosest}` | `LV30/H010` | -| `L{hdg}` / `R{hdg}` | `ConditionalHeading{Turn=Left/Right}` | `LV130/R100` | -| `L{deg}D` / `R{deg}D` | `ConditionalHeading{ByDegrees=N, Turn=Left/Right}` | `LV30/L20D` | -| `D{fix}` | `ConditionalDirectFix{Turn=TurnClosest}` | `RC100/DAAC` | -| `LD{fix}` / `RD{fix}` | `ConditionalDirectFix{Turn=Left/Right}` | `RC100/LDAAC` | -| `S{spd}` | `ConditionalSpeed{...}` | `RC50/S210` | -| `M{mach}` | `ConditionalMach{...}` | `RC350/M78` | - -Altitude-changing inner commands (`C`, `CVS`, `DVS`, `ED`, etc.) are explicitly rejected by the parser's default branch. No separate check. - -## Command parsing & dispatch - -In `sim/control.go`, `runOneControlCommand`: - -**Case `'L'`** — add a new branch before the existing `LD` / `LD` / `L` branches: - -```go -if strings.HasPrefix(command, "LV") && len(command) > 2 { - altStr, inner, ok := strings.Cut(command[2:], "/") - if !ok || inner == "" { return nil, ErrInvalidCommandSyntax } - alt, err := parseConditionalAltitude(altStr) - if err != nil { return nil, err } - action, err := parseConditionalAction(inner) - if err != nil { return nil, err } - return s.AssignConditional(tcw, callsign, ConditionalLeaving, alt, action) -} -``` - -**Case `'R'`** — analogous branch for `RC`, placed before the existing `RR` branch to avoid accidental fallthrough. - -**Altitude encoding** — reuse the `RR{alt}` convention: - -```go -func parseConditionalAltitude(s string) (float32, error) { - n, err := strconv.Atoi(s) - if err != nil { return 0, err } - if n > 600 && n%100 == 0 { n /= 100 } - return float32(n * 100), nil -} -``` - -**Inner parser** — `parseConditionalAction(inner string) (ConditionalAction, error)`: -Switch on `inner[0]` ∈ {H, L, R, D, S, M}; each branch validates its sub-grammar and returns the corresponding concrete `ConditionalAction`. Default branch returns `ErrInvalidCommandSyntax`, which naturally rejects altitude-changing inner commands. - -**Sim entry point:** - -```go -func (s *Sim) AssignConditional(tcw TCW, callsign av.ADSBCallsign, - kind ConditionalKind, altitude float32, action ConditionalAction) (av.CommandIntent, error) { - - s.mu.Lock(s.lg); defer s.mu.Unlock(s.lg) - - return s.dispatchControlledAircraftCommand(tcw, callsign, - func(tcw TCW, ac *Aircraft) av.CommandIntent { - if !triggerReachable(ac, kind, altitude) { - return nil // caller treats nil-intent as "unable" - } - ac.Nav.PendingConditionalCommand = &PendingConditionalCommand{ - Kind: kind, Altitude: altitude, Action: action, - } - return av.ConditionalCommandIntent{ - Kind: kind, Altitude: altitude, Action: action, - } - }) -} -``` - -**Reachability validation:** - -```go -func triggerReachable(ac *Aircraft, kind ConditionalKind, trigger float32) bool { - cur := ac.Altitude() - target := ac.Nav.Altitude.Assigned - switch kind { - case ConditionalLeaving: - if math.Abs(cur-trigger) <= 500 { return true } - if target == nil { return false } - return between(trigger, cur, *target) - case ConditionalReaching: - if target == nil { return math.Abs(cur-trigger) <= 500 } - return between(trigger, cur, *target) - } - return false -} -``` - -The 500 ft slack on `LV` handles "aircraft is at 3,050 climbing, controller says leaving 3,000" — shouldn't reject. - -## Trigger evaluation & firing - -In `sim.updateState` (adjacent to the existing `ReportReachingAltitude` check): - -```go -if pc := ac.Nav.PendingConditionalCommand; pc != nil && ac.IsAssociated() { - if conditionalTriggered(ac, pc) { - action := pc.Action - ac.Nav.PendingConditionalCommand = nil // clear BEFORE execute to prevent re-entry - action.Execute(&ac.Nav, s.State.SimTime) - } -} -``` - -**Trigger predicate:** - -```go -func conditionalTriggered(ac *Aircraft, pc *PendingConditionalCommand) bool { - alt := ac.Altitude() - diff := alt - pc.Altitude - switch pc.Kind { - case ConditionalLeaving: - // Fires once altitude is >50 ft past trigger in the direction of current vertical motion. - return math.Abs(diff) > 50 && - sameSign(diff, ac.Nav.FlightState.AltitudeRate) - case ConditionalReaching: - // First contact within 100 ft, regardless of vertical rate. - return math.Abs(diff) <= 100 - } - return false -} -``` - -The 50 ft `LV` threshold prevents firing on level-flight altitude noise. The 100 ft `RC` threshold matches the existing `RR{alt}` tolerance. - -**Action execution** — each concrete `ConditionalAction.Execute` calls the corresponding existing `Nav` method directly (`AssignHeading`, `DirectFix`, `AssignSpeed`, `AssignMach`). Because execution bypasses `runOneControlCommand`, no readback transmission is generated — silent execution is free. - -## Readback rendering - -```go -// aviation/intent.go -type ConditionalCommandIntent struct { - Kind ConditionalKind - Altitude float32 - Action ConditionalAction -} - -func (c ConditionalCommandIntent) Render(rt *RadioTransmission, r *rand.Rand) { - switch c.Kind { - case ConditionalLeaving: - rt.Add("[leaving|passing] {alt}, ", c.Altitude) - case ConditionalReaching: - rt.Add("[reaching|level at|on reaching] {alt}, ", c.Altitude) - } - c.Action.Render(rt, r) -} -``` - -Each `ConditionalAction.Render` emits only the action fragment (e.g., "fly heading 010", "direct AAC", "reduce speed to 210"), reusing the existing phraseology vocabulary. Concrete implementations draw on patterns from `AltitudeIntent`, `HeadingIntent`, `SpeedIntent`, `DirectFixIntent`. - -Example readbacks: - -| Command | Rendered | -|---|---| -| `LV30/H010` | "leaving three thousand, fly heading zero one zero" | -| `LV130/R100` | "passing one three thousand, right heading one zero zero" | -| `RC100/DAAC` | "reaching one zero thousand, direct alpha alpha charlie" | -| `RC50/S210` | "reaching five thousand, slowing to two one zero" | - -No `PendingTransmission*` type is added — trigger firing is silent by design. - -## STT grammar - -Voice support in `stt/handlers.go` — register one pattern per `(trigger × inner)` combination. The LV/RC trigger prefix is bolted onto each inner command's grammar fragment. - -**Trigger phrases** (alternation): - -- `LV`: "leaving {alt}" / "passing {alt}" -- `RC`: "reaching {alt}" / "level at {alt}" / "on reaching {alt}" - -**Inner phrases** — the established vocabulary for each supported inner command (from existing STT handlers): - -- `H{hdg}`: "fly heading {hdg}" -- `L{hdg}` / `R{hdg}`: "turn left|right heading {hdg}" -- `L{deg}D` / `R{deg}D`: "turn left|right {deg} degrees" -- `D{fix}`: "direct {fix}" / "proceed direct {fix}" -- `LD{fix}` / `RD{fix}`: "turn left|right direct {fix}" -- `S{spd}`: "maintain {spd}" / "reduce speed to {spd}" / "speed {spd}" -- `M{mach}`: "maintain mach {mach}" / "mach {mach}" - -**Implementation approach** — loop programmatically over the inner set, registering one `stt` command per pair: - -```go -for _, inner := range innerPatterns { - registerSTTCommand( - fmt.Sprintf("leaving|passing {altitude}, %s", inner.Grammar), - func(alt int, args ...any) string { - return fmt.Sprintf("LV%d/%s", alt, inner.ToCommand(args)) - }, - WithName("conditional_leaving_" + inner.Name), - WithPriority(11), - ) - // analogous for reaching -} -``` - -Exact framework fit (named rules vs. inline expansion) to be confirmed during implementation — the STT framework may need a small accommodation. The fallback of N inline registrations is acceptable. - -**Priority tuning:** set distinct priorities so "reaching {alt}" (for the new RC command) doesn't fuzzy-match with "report reaching {alt}" (the existing RR command). The existing `say<->stop` precedent from commit `3caf4fac` shows the pattern. - -## Testing - -### Unit tests (`nav` package) - -- Trigger predicate truth table for each `ConditionalKind` (climbing through, descending through, level at, level below, within-tolerance noise). -- Supersession behavior (new slot replaces prior). -- Persistence across synthetic handoff state transitions. -- `Execute` correctness per concrete action type — assert mutation matches direct nav-method call. - -### Sim-layer tests (`sim/control_test.go`) - -- Parse-and-install happy path, one row per `(trigger, inner)` combination. -- Parser rejections: altitude-changing inner, malformed altitude, empty inner, unknown inner prefix, missing slash. -- Unreachable-trigger rejections for each kind. -- Readback render round-trip for each action type. - -### End-to-end tests (`sim/e2e_test.go`) - -- `LV` scenario: aircraft climbing through trigger altitude; assert heading changes at the correct tick with no extra radio transmission at fire time. -- `RC` scenario: aircraft reaching target altitude; assert direct-fix installed, slot cleared, silent fire. - -### STT tests (`stt/handlers_test.go`) - -- One happy-path per registered voice pattern. -- Adversarial fuzzy-match guards — specifically verify "reaching {alt}" does not fire the `RR` command path and vice versa. - -### Regression hygiene - -- `go test ./sim/... ./nav/... ./stt/... ./aviation/...` must pass at every intermediate commit. -- No existing tests modified; this is pure addition. - -## Out of scope - -- Queuing multiple pending LV/RC actions per aircraft (single-slot only). -- Altitude-changing inner commands. -- Conditional commands triggered by events other than altitude (speed, time, position) — those exist as separate grammars (`A{fix}/...`, speed-until). -- Compound inner commands like speed-until (`S250/UFIX1/210`) nested inside LV/RC.