diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml index b6c0c28..dccd92a 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml +++ b/src/Nonemm.App/Windows/EntryWindow.axaml @@ -211,6 +211,11 @@ + + + diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index 5647d4e..7e223a6 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -6,6 +6,7 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Threading; using Nonemm.Contests; +using Nonemm.Contests.Rules; using Nonemm.Core; using Nonemm.App.Theming; using Nonemm.Session; @@ -799,6 +800,7 @@ public sealed partial class EntryWindow : Window Title = TitleLine(); ShowCallFrame(); PathText.Text = PathLine(); + ShowQtcInfo(); UserTextText.Text = Logging.Session.History.Find(Logging.Entry.Call)?.UserText ?? ""; ShowLights(); @@ -950,6 +952,24 @@ public sealed partial class EntryWindow : Window + $"{path.DistanceKm:N0} km · {path.DistanceKm * MilesPerKilometre:N0} mi{sun}"; } + /// WAE's QTC tally for the station being worked, which N1MM keeps in a + /// blue label under the entry boxes: how many lines have gone with it, how + /// many more may go, and how many contacts are left to report to anybody. + /// The line is only there in WAE, because no other contest has traffic. + private void ShowQtcInfo() + { + if (Logging is null || Logging.Contest is not Wae) + { + QtcInfoText.IsVisible = false; + return; + } + string call = Logging.Entry.Call.Trim(); + QtcCounts counts = new QtcTraffic(Logging) + .CountsFor(Callsign.Parse(call.Length >= 3 ? call : "")); + QtcInfoText.Text = counts.IsFull ? $"{counts.Text} — at the limit" : counts.Text; + QtcInfoText.IsVisible = true; + } + /// A blank line where a bearing should be is a puzzle. This says which of /// the two ends could not be placed and what to do about it. private string WhyNoPath() diff --git a/src/Nonemm.App/Windows/QtcWindow.axaml b/src/Nonemm.App/Windows/QtcWindow.axaml index 597e96d..41470ee 100644 --- a/src/Nonemm.App/Windows/QtcWindow.axaml +++ b/src/Nonemm.App/Windows/QtcWindow.axaml @@ -11,6 +11,9 @@ + + diff --git a/src/Nonemm.App/Windows/QtcWindow.axaml.cs b/src/Nonemm.App/Windows/QtcWindow.axaml.cs index 4743be3..090a334 100644 --- a/src/Nonemm.App/Windows/QtcWindow.axaml.cs +++ b/src/Nonemm.App/Windows/QtcWindow.axaml.cs @@ -98,7 +98,7 @@ public sealed partial class QtcWindow : Window return isSending ? IsRtty ? "Send All reads the whole series out — Snd n sends one QTC again" - : $"{traffic.Remaining(station)} of the ten QTCs for {station.Text} are still free" + : "Nothing goes out on SSB: read the lines out yourself, then Close to log them." : "Type the header, then a line per QTC: time, callsign, serial number."; } return isSending @@ -132,6 +132,7 @@ public sealed partial class QtcWindow : Window private void Reset() { Title = isSending ? $"Send QTC · {station.Text}" : $"Receive QTC · {station.Text}"; + CountsText.Text = traffic.CountsFor(station).Text; KeysText.Text = KeysLine(); // N1MM's labels: the buttons say what they do in the direction the // traffic is going, and the ones with nothing to do are hidden diff --git a/src/Nonemm.Session/QtcTraffic.cs b/src/Nonemm.Session/QtcTraffic.cs index 440a383..2897be8 100644 --- a/src/Nonemm.Session/QtcTraffic.cs +++ b/src/Nonemm.Session/QtcTraffic.cs @@ -15,6 +15,28 @@ public enum QtcDirection Either, } +/// The QTC tally for one station, as N1MM writes it beside the callsign: +/// how many lines have already gone with it, how many more may go, and how +/// many logged contacts are left to report to anybody. +public sealed record QtcCounts(QtcDirection Role, int Exchanged, int LeftWith, int Available) +{ + /// N1MM's wording, one line per role. Europe on CW and SSB only takes + /// traffic down, so there is no pile of contacts to report and the line + /// leaves that number out. + public string Text => Role switch + { + QtcDirection.Receive => $"QTCs: {Exchanged} rcvd/{LeftWith} left to exch.", + QtcDirection.Send => + $"QTCs: {Exchanged} sent/{LeftWith} left to exch/{Available} total QTCs avail", + QtcDirection.Either => + $"QTCs: {Exchanged} exch/{LeftWith} left to exch/{Available} total QTCs avail", + _ => "", + }; + + /// N1MM puts this in the status bar as well when a station has had its ten. + public bool IsFull => Exchanged >= WaeQtc.MostPerStation; +} + /// The WAE QTC rules that do not need a window: who may exchange traffic with /// whom, which contacts are still worth reporting, how the series are numbered, /// and whether a line an operator has typed reads as a QTC. @@ -87,6 +109,46 @@ public sealed class QtcTraffic public int Remaining(Callsign other) => Math.Max(0, WaeQtc.MostPerStation - ExchangedWith(other)); + /// Which way traffic goes here whoever is worked: Europe takes it down on + /// CW and SSB, the rest of the world reads it out, and on RTTY it goes + /// both ways. `DirectionFor` answers the same question about one station; + /// this one ignores the station, so the counts still read out when the + /// callsign box is empty or holds a station with nothing left. + public QtcDirection Role + { + get + { + if (position.Contest is not Wae contest) + { + return QtcDirection.None; + } + if (contest.Modes.Contains(ModeCategory.Digital)) + { + return QtcDirection.Either; + } + return position.Me.Continent == "EU" ? QtcDirection.Receive : QtcDirection.Send; + } + } + + /// How many logged contacts are still worth reporting to this station: the + /// whole pile rather than one series of ten. This is N1MM's + /// `QTCRecordsetCount`, which is zero for a European station on CW and SSB + /// because it only takes traffic down. + public int Available(Callsign other) => + Role == QtcDirection.Receive ? 0 : Reportable(other).Count(); + + /// The three numbers the entry window shows. `LeftWith` is what may still + /// go with this station: what is free of its ten, and no more than the + /// contacts there are to report when we are the one reporting. + public QtcCounts CountsFor(Callsign other) + { + int exchanged = ExchangedWith(other); + int free = Math.Max(0, WaeQtc.MostPerStation - exchanged); + int available = Available(other); + int left = Role == QtcDirection.Send ? Math.Min(free, available) : free; + return new QtcCounts(Role, exchanged, left, available); + } + /// Series are numbered across the whole contest, not per station: the /// hundredth series a station sends is `100/10` whoever gets it. public int NextSeries() @@ -111,18 +173,7 @@ public sealed class QtcTraffic { return []; } - HashSet reported = position.Log.Qsos - .Where(q => q.Exchange1.Equals(WaeQtc.Sent, StringComparison.OrdinalIgnoreCase)) - .Select(q => ReportedKey(q.ReceivedReport, q.ReceivedNumber)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - List worth = position.Log.Qsos - .Where(q => !WaeQtc.IsQtc(q)) - .Where(q => q.ContactType.Length == 0) - .Where(q => !q.Call.Text.Equals(other.Text, StringComparison.OrdinalIgnoreCase)) - .Where(q => !reported.Contains(ReportedKey(q.Call.Text, q.SentNumber))) - .OrderBy(q => q.TimestampUtc) - .Take(room) - .ToList(); + List worth = [.. Reportable(other).OrderBy(q => q.TimestampUtc).Take(room)]; int series = NextSeries(); return [.. worth.Select(q => new WaeQtc( other, @@ -196,6 +247,22 @@ public sealed class QtcTraffic private static bool IsClockTime(string text) => int.Parse(text[..2]) < 24 && int.Parse(text[2..]) < 60; + /// The contacts not reported to anybody yet, without the dupes and without + /// the station in front of us. The caller puts them in order. What N1MM's + /// `QTCRecordsetCount` leaves out is the dupes. + private IEnumerable Reportable(Callsign other) + { + HashSet reported = position.Log.Qsos + .Where(q => q.Exchange1.Equals(WaeQtc.Sent, StringComparison.OrdinalIgnoreCase)) + .Select(q => ReportedKey(q.ReceivedReport, q.ReceivedNumber)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return position.Log.Qsos + .Where(q => !WaeQtc.IsQtc(q)) + .Where(q => q.ContactType.Length == 0) + .Where(q => !q.Call.Text.Equals(other.Text, StringComparison.OrdinalIgnoreCase)) + .Where(q => !reported.Contains(ReportedKey(q.Call.Text, q.SentNumber))); + } + private static string ReportedKey(string call, int number) => $"{call}|{number}"; private string ContinentOf(Callsign call) => diff --git a/tests/Nonemm.Session.Tests/QtcTrafficTests.cs b/tests/Nonemm.Session.Tests/QtcTrafficTests.cs index 4acf5b1..8abcdbf 100644 --- a/tests/Nonemm.Session.Tests/QtcTrafficTests.cs +++ b/tests/Nonemm.Session.Tests/QtcTrafficTests.cs @@ -72,6 +72,26 @@ public class QtcTrafficTests Exchange1 = WaeQtc.Contact, }); + /// One line of traffic with a station, as a log row of its own. What is + /// reported does not matter to the counts, only that the line is there. + private static Qso Traffic( + OperatingPosition position, + string station, + bool isSent, + int number, + int minute) => + position.Session.Add(new WaeQtc( + Callsign.Parse(station), isSent, 1, 1, "1300", Callsign.Parse("W1XX"), number) + .ApplyTo(new Qso + { + Id = Qso.NewId(), + TimestampUtc = new DateTime(2026, 11, 14, 13, minute, 0, DateTimeKind.Utc), + Call = Callsign.Parse(station), + Frequency = Frequency.FromKilohertz(14_080), + Mode = Modes.Rtty, + ContestName = "WAERTTY", + })); + [Fact] public void OnCwEuropeReceivesAndTheRestOfTheWorldSends() { @@ -167,6 +187,100 @@ public class QtcTrafficTests Assert.Equal(87, traffic.NextSeries()); } + /// Europe on CW and SSB only takes traffic down, so there is no pile of + /// contacts to report and the line says how many of the ten are left. + [Fact] + public void EuropeCountsWhatItHasTakenDown() + { + (OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Cw); + Contact(position, "K1ABC", receivedNumber: 5, sentNumber: 1, minute: 40); + Traffic(position, "NE1C", isSent: false, number: 7, minute: 41); + + QtcCounts counts = traffic.CountsFor(Callsign.Parse("NE1C")); + + Assert.Equal(QtcDirection.Receive, counts.Role); + Assert.Equal(1, counts.Exchanged); + Assert.Equal(9, counts.LeftWith); + Assert.Equal(0, counts.Available); + Assert.Equal("QTCs: 1 rcvd/9 left to exch.", counts.Text); + } + + /// Outside Europe the traffic goes the other way, so what may still be + /// exchanged is capped by the contacts there are left to report. + [Fact] + public void OutsideEuropeWhatIsLeftIsCappedByWhatThereIsToReport() + { + (OperatingPosition position, QtcTraffic traffic) = + Station(ModeCategory.Cw, UnitedStates); + Contact(position, "DL1AAA", receivedNumber: 16, sentNumber: 1, minute: 46); + Contact(position, "DL2BBB", receivedNumber: 40, sentNumber: 2, minute: 47); + Contact(position, "DL9XYZ", receivedNumber: 7, sentNumber: 3, minute: 48); + + QtcCounts counts = traffic.CountsFor(Callsign.Parse("DL9XYZ")); + + Assert.Equal(QtcDirection.Send, counts.Role); + Assert.Equal(0, counts.Exchanged); + // the station in front of us is not reported to itself + Assert.Equal(2, counts.Available); + Assert.Equal(2, counts.LeftWith); + Assert.Equal("QTCs: 0 sent/2 left to exch/2 total QTCs avail", counts.Text); + } + + /// A contact already reported to somebody is out of the pile, and a line + /// already sent to this station is off its ten. + [Fact] + public void ReportingContactsTakesThemOutOfTheCounts() + { + (OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital); + Contact(position, "DL1AAA", receivedNumber: 16, sentNumber: 1, minute: 46); + Contact(position, "DL2BBB", receivedNumber: 40, sentNumber: 2, minute: 47); + WaeQtc first = traffic.ToSend(Callsign.Parse("JA1XYZ"))[0]; + position.Session.Add(first.ApplyTo(new Qso + { + Id = Qso.NewId(), + TimestampUtc = new DateTime(2026, 11, 14, 13, 50, 0, DateTimeKind.Utc), + Call = Callsign.Parse("JA1XYZ"), + Frequency = Frequency.FromKilohertz(14_080), + Mode = Modes.Rtty, + ContestName = "WAERTTY", + })); + + QtcCounts counts = traffic.CountsFor(Callsign.Parse("JA1XYZ")); + + Assert.Equal(QtcDirection.Either, counts.Role); + Assert.Equal(1, counts.Exchanged); + Assert.Equal(1, counts.Available); + Assert.Equal("QTCs: 1 exch/9 left to exch/1 total QTCs avail", counts.Text); + } + + /// On RTTY traffic goes both ways, so what is left with a station is what + /// is free of its ten whether or not there is anything to report. + [Fact] + public void OnRttyWhatIsLeftIsNotCappedByWhatThereIsToReport() + { + (_, QtcTraffic traffic) = Station(ModeCategory.Digital); + + QtcCounts counts = traffic.CountsFor(Callsign.Parse("JA1XYZ")); + + Assert.Equal(0, counts.Available); + Assert.Equal(10, counts.LeftWith); + } + + [Fact] + public void AStationThatHasHadItsTenIsFull() + { + (OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital); + for (int at = 1; at <= 10; at++) + { + Traffic(position, "JA1XYZ", isSent: true, number: at, minute: at); + } + + QtcCounts counts = traffic.CountsFor(Callsign.Parse("JA1XYZ")); + + Assert.True(counts.IsFull); + Assert.Equal(0, counts.LeftWith); + } + [Theory] [InlineData("1346", "DL2UH", "40")] [InlineData("0001", "2E0GYI", "1")]