diff --git a/aviation/intent.go b/aviation/intent.go index 89b7bba59..f831a394c 100644 --- a/aviation/intent.go +++ b/aviation/intent.go @@ -889,16 +889,18 @@ func (a ATISIntent) Render(rt *RadioTransmission, r *rand.Rand) { type TrafficAdvisoryResponse int const ( - TrafficResponseIMC TrafficAdvisoryResponse = iota // In IMC, can't see traffic - TrafficResponseLooking // No traffic visible, will look - TrafficResponseTrafficSeen // Traffic is in sight - TrafficResponseAcknowledged // Other traffic is maintaining visual separation + TrafficResponseIMC TrafficAdvisoryResponse = iota // In IMC, can't see traffic + TrafficResponseLooking // No traffic visible, will look + TrafficResponseTrafficSeen // Traffic is in sight + TrafficResponseAcknowledged // Other traffic is maintaining visual separation + TrafficResponseWrongQuadrant // Pilot sees traffic, but at a different o'clock than reported ) // TrafficAdvisoryIntent represents a pilot's response to a traffic advisory type TrafficAdvisoryIntent struct { Response TrafficAdvisoryResponse WillMaintainSeparation bool // If true, add "will maintain visual separation" + ActualOclock int // For TrafficResponseWrongQuadrant: the o'clock at which the pilot actually sees the traffic } func (t TrafficAdvisoryIntent) Render(rt *RadioTransmission, r *rand.Rand) { @@ -915,6 +917,9 @@ func (t TrafficAdvisoryIntent) Render(rt *RadioTransmission, r *rand.Rand) { } case TrafficResponseAcknowledged: rt.Add("[roger|copy the traffic|roger, we have the traffic]") + case TrafficResponseWrongQuadrant: + rt.Add("[traffic at our {num} o'clock in sight|we have traffic at our {num} o'clock]", + t.ActualOclock) } } diff --git a/sim/aircraft.go b/sim/aircraft.go index d2e6c9089..75013e210 100644 --- a/sim/aircraft.go +++ b/sim/aircraft.go @@ -44,8 +44,9 @@ type SeenAircraft struct { } type UnseenTrafficCall struct { - Callsign av.ADSBCallsign - CalledTime Time + Callsign av.ADSBCallsign + CalledTime Time + WhereAskFireTime Time // If non-zero and passed, pilot proactively asks "where's that traffic" } type Aircraft struct { diff --git a/sim/commands.go b/sim/commands.go index 0d9f0c598..12f409108 100644 --- a/sim/commands.go +++ b/sim/commands.go @@ -490,39 +490,27 @@ func (s *Sim) handleTrafficAdvisory(ac *Aircraft, oclock int, miles int, traffic return av.TrafficAdvisoryIntent{Response: av.TrafficResponseIMC} } - // Convert o'clock to heading offset from aircraft heading - // 12 o'clock = 0 degrees, 3 o'clock = 90 degrees, etc. - oclockHeading := math.MagneticHeading((oclock % 12) * 30) // 0, 30, 60, 90... 330 - trafficHeading := math.NormalizeHeading(ac.Heading() + oclockHeading) + kind, trafficFound, actualOclock := s.findAdvisoryTraffic(ac, oclock, miles, trafficAltFeet) - // Calculate the approximate position of the reported traffic - nmPerLong := ac.NmPerLongitude() - magVar := ac.MagneticVariation() - trafficPos := math.Offset2LL(ac.Position(), math.MagneticToTrue(trafficHeading, magVar), float32(miles), nmPerLong) - - // Search for actual traffic near the reported position - // Tolerance: +/- 2 miles horizontal, +/- 1000 feet vertical - const horizontalToleranceNM = 2.0 - const verticalToleranceFeet = 1000.0 - - var trafficFound av.ADSBCallsign - trafficDist := float32(999999) - for cs, other := range s.Aircraft { - if cs == ac.ADSBCallsign { - continue // Skip self + switch kind { + case advisoryMatchWrongQuadrant: + // Real traffic nearby but at a different o'clock than reported — pilot + // reports it at the corrected clock position. + sighting := ac.RecordSighting(trafficFound, s.State.SimTime) + sighting.OfferedToMaintainSeparation = false + ac.clearUnseenTrafficCall() + return av.TrafficAdvisoryIntent{ + Response: av.TrafficResponseWrongQuadrant, + ActualOclock: actualOclock, } - dist := math.NMDistance2LL(trafficPos, other.Position()) - altDiff := math.Abs(other.Altitude() - trafficAltFeet) - - if dist <= horizontalToleranceNM && altDiff <= verticalToleranceFeet && dist < trafficDist { - trafficFound = cs - trafficDist = dist + case advisoryMatchNone: + // No traffic anywhere nearby — respond "looking" and schedule the + // proactive "where's that traffic" follow-up. + ac.UnseenTrafficCall = &UnseenTrafficCall{ + CalledTime: s.State.SimTime, + WhereAskFireTime: s.State.SimTime.Add(s.Rand.DurationRange(30*time.Second, 60*time.Second)), } - } - - if trafficFound == "" { - // No traffic found - respond "looking" return av.TrafficAdvisoryIntent{Response: av.TrafficResponseLooking} } @@ -557,11 +545,97 @@ func (s *Sim) handleTrafficAdvisory(ac *Aircraft, oclock int, miles int, traffic } } - // "Looking" - schedule possible delayed traffic-in-sight call + // "Looking" - schedule possible delayed traffic-in-sight call, plus a + // longer "where's that traffic" follow-up if they never spot it. s.enqueueFutureTrafficInSight(ac.ADSBCallsign, trafficFound) + ac.UnseenTrafficCall.WhereAskFireTime = s.State.SimTime.Add(s.Rand.DurationRange(30*time.Second, 60*time.Second)) return av.TrafficAdvisoryIntent{Response: av.TrafficResponseLooking} } +type advisoryMatchKind int + +const ( + advisoryMatchNone advisoryMatchKind = iota // no plausible traffic anywhere nearby + advisoryMatchExact // traffic at the reported point (within 2 NM, 1000 ft) + advisoryMatchWrongQuadrant // traffic nearby but in a different o'clock (within 15°-60° of the reported bearing) +) + +// findAdvisoryTraffic walks s.Aircraft once looking for traffic that matches +// the controller-issued advisory. Returns: +// - advisoryMatchExact when the closest target within ±2 NM and ±1000 ft of +// the reported point qualifies. +// - advisoryMatchWrongQuadrant when no exact match exists but the closest +// target within (5 + 0.5*miles) NM and ±1000 ft sits at a bearing 15-60° +// off the reported one. Returns the actual o'clock the pilot sees. +// - advisoryMatchNone otherwise. +func (s *Sim) findAdvisoryTraffic(ac *Aircraft, reportedOclock, reportedMiles int, trafficAltFeet float32) (advisoryMatchKind, av.ADSBCallsign, int) { + const horizontalToleranceNM = 2.0 + const verticalToleranceFeet = 1000.0 + const wqMinAngle = 15.0 // below this, treat as exact (same o'clock bin) + const wqMaxAngle = 60.0 // above this, too far off to be "that" traffic + wqMaxRangeNM := float32(5) + 0.5*float32(reportedMiles) + + nmPerLong := ac.NmPerLongitude() + magVar := ac.MagneticVariation() + + // Reported bearing from ac (true), and the corresponding target point. + reportedRelHeading := math.MagneticHeading((reportedOclock % 12) * 30) + reportedHeadingMag := math.NormalizeHeading(ac.Heading() + reportedRelHeading) + reportedHeadingTrue := math.MagneticToTrue(reportedHeadingMag, magVar) + reportedPos := math.Offset2LL(ac.Position(), reportedHeadingTrue, float32(reportedMiles), nmPerLong) + + var ( + exactCS av.ADSBCallsign + exactDist = float32(999999) + wqCS av.ADSBCallsign + wqDist = float32(999999) + ) + + for cs, other := range s.Aircraft { + if cs == ac.ADSBCallsign { + continue + } + if math.Abs(other.Altitude()-trafficAltFeet) > verticalToleranceFeet { + continue + } + + // Exact match: close to the reported point. + distFromReported := math.NMDistance2LL(reportedPos, other.Position()) + if distFromReported <= horizontalToleranceNM && distFromReported < exactDist { + exactCS = cs + exactDist = distFromReported + } + + // Wrong-quadrant candidate: within the wider radius from ac itself, at + // a bearing offset from the reported one. + distFromAc := math.NMDistance2LL(ac.Position(), other.Position()) + if distFromAc > wqMaxRangeNM { + continue + } + bearingTrue := math.Heading2LL(ac.Position(), other.Position(), nmPerLong) + angleOff := math.HeadingDifference(bearingTrue, reportedHeadingTrue) + if angleOff < wqMinAngle || angleOff > wqMaxAngle { + continue + } + if distFromAc < wqDist { + wqCS = cs + wqDist = distFromAc + } + } + + if exactCS != "" { + return advisoryMatchExact, exactCS, reportedOclock + } + if wqCS != "" { + other := s.Aircraft[wqCS] + bearingTrue := math.Heading2LL(ac.Position(), other.Position(), nmPerLong) + bearingMag := math.TrueToMagnetic(bearingTrue, magVar) + rel := math.NormalizeHeading(float32(bearingMag) - float32(ac.Heading())) + return advisoryMatchWrongQuadrant, wqCS, math.HeadingAsHour(rel) + } + return advisoryMatchNone, "", 0 +} + // nearestMETAR returns the METAR and airport elevation for the reporting // station closest to pos. Returns a zero METAR if no stations are available. func (s *Sim) nearestMETAR(pos math.Point2LL) (wx.METAR, float32) { diff --git a/sim/radio.go b/sim/radio.go index 8efa93ac7..e77bd62fe 100644 --- a/sim/radio.go +++ b/sim/radio.go @@ -55,6 +55,7 @@ const ( PendingTransmissionRequestVisual // Spontaneous "field in sight, requesting visual" PendingTransmissionRequestVectors // Pilot requesting vectors (overshot localizer) PendingTransmissionRequestAltitude // Pilot requesting altitude after being vectored off STAR + PendingTransmissionTrafficWhere // Pilot proactively asks "where's that traffic" after 30-60s of looking ) // FutureFrequencyChange represents a pilot switching to a new frequency. @@ -423,6 +424,10 @@ func (s *Sim) GenerateContactTransmission(pc *PendingContact) (spokenText, writt case PendingTransmissionTrafficInSight: rt = av.MakeContactTransmission("[we've got the traffic|we have the traffic in sight|traffic in sight now]") + case PendingTransmissionTrafficWhere: + rt = av.MakeContactTransmission("[where's that traffic|request update on that traffic|we still don't have the traffic]") + rt.Type = av.RadioTransmissionUnexpected + case PendingTransmissionFieldInSight: rt = av.MakeContactTransmission("[we have the field in sight now|field in sight|we have the airport in sight now]") diff --git a/sim/sim.go b/sim/sim.go index b51afba3b..a88e6ae9c 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -1166,11 +1166,22 @@ func (s *Sim) updateState() { if math.NMDistance2LL(ac.Position(), s.State.Center) > maxDist { s.lg.Debug("culled far-away aircraft", slog.String("adsb_callsign", string(callsign))) s.deleteAircraft(ac) + continue } // Enqueue a spontaneous "field in sight" transmission if the pilot // wants to report and the field is currently visible. s.checkSpontaneousVisualRequest(ac) + + // Pilot proactively asks "where's that traffic" 30-60s after a + // "looking" reply if they still haven't sighted it. + if utc := ac.UnseenTrafficCall; utc != nil && !utc.WhereAskFireTime.IsZero() && + s.State.SimTime.After(utc.WhereAskFireTime) { + utc.WhereAskFireTime = Time{} + if ac.ControllerFrequency != "" { + s.enqueuePilotTransmission(ac.ADSBCallsign, TCP(ac.ControllerFrequency), PendingTransmissionTrafficWhere) + } + } } s.possiblyRequestFlightFollowing() diff --git a/sim/visual_approach_test.go b/sim/visual_approach_test.go index 0c917fbe4..ad460decb 100644 --- a/sim/visual_approach_test.go +++ b/sim/visual_approach_test.go @@ -1348,8 +1348,8 @@ func TestTrafficAdvisoryClearsOfferedStateButKeepsSightingHistory(t *testing.T) if sighting.OfferedToMaintainSeparation { t.Fatal("new traffic advisory should clear stale offered-to-maintain state") } - if vs.AC.UnseenTrafficCall != nil { - t.Fatal("no-traffic advisory response should clear the unresolved unseen traffic call") + if utc := vs.AC.UnseenTrafficCall; utc != nil && utc.Callsign == "DAL456" { + t.Fatal("no-traffic advisory response should clear the prior unresolved unseen traffic call (for DAL456)") } if len(vs.Sim.FutureTrafficInSights) != 0 { t.Fatal("new traffic advisory should cancel stale delayed traffic-in-sight events")