using Nonemm.Contests; using Nonemm.Contests.Rules; using Nonemm.Core; namespace Nonemm.Session; /// Which way QTC traffic goes with a station, if it goes at all. public enum QtcDirection { None, Send, Receive, /// RTTY, where traffic goes both ways and the operator picks. 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. /// /// On CW and SSB, traffic goes one way only: the station outside Europe reports /// its contacts to the European station. On RTTY it goes both ways, and the /// only rule left is that the two stations are on different continents. public sealed class QtcTraffic { private readonly OperatingPosition position; public QtcTraffic(OperatingPosition position) => this.position = position; public bool IsWaeContest => position.Contest is Wae; /// Which way traffic would go with this station. `reason` says why not when /// the answer is `None`, in the words the status line shows. public QtcDirection DirectionFor(Callsign other, out string reason) { reason = ""; if (position.Contest is not Wae contest) { reason = "QTC traffic is a WAE thing"; return QtcDirection.None; } if (other.Text.Length < 3) { reason = "work a station first"; return QtcDirection.None; } if (Remaining(other) <= 0) { reason = $"{other.Text} has had its ten QTCs"; return QtcDirection.None; } string theirs = ContinentOf(other); bool sameContinent = theirs.Length > 0 && theirs == position.Me.Continent; if (contest.Modes.Contains(ModeCategory.Digital)) { if (sameContinent) { reason = $"{other.Text} is on this continent"; return QtcDirection.None; } return QtcDirection.Either; } if (position.Me.Continent == "EU") { if (theirs == "EU") { reason = $"{other.Text} is in Europe"; return QtcDirection.None; } return QtcDirection.Receive; } if (theirs != "EU") { reason = $"{other.Text} is not in Europe"; return QtcDirection.None; } return QtcDirection.Send; } /// How many QTC lines have already gone either way with this station. Ten /// is the lot, for the whole contest and whatever the band. public int ExchangedWith(Callsign other) => position.Log.Qsos.Count(q => WaeQtc.IsQtc(q) && q.Call.Text.Equals(other.Text, StringComparison.OrdinalIgnoreCase)); 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() { int last = position.Log.Qsos .Where(q => q.Exchange1.Equals(WaeQtc.Sent, StringComparison.OrdinalIgnoreCase)) .Select(q => WaeQtc.From(q)?.Series ?? 0) .DefaultIfEmpty(0) .Max(); return last + 1; } /// The contacts to report to this station: the ones not reported to anybody /// yet, oldest first, and never the station itself. Ten at a time unless /// the operator asks for a shorter series, and fewer when the station has /// already had some. A contact marked as a dupe is not offered, which is /// what N1MM's `QTCRecordsetCount` leaves out. public IReadOnlyList ToSend(Callsign other, int mostPerSeries = WaeQtc.MostPerSeries) { int room = Math.Min(Math.Clamp(mostPerSeries, 1, WaeQtc.MostPerSeries), Remaining(other)); if (room <= 0) { return []; } List worth = [.. Reportable(other).OrderBy(q => q.TimestampUtc).Take(room)]; int series = NextSeries(); return [.. worth.Select(q => new WaeQtc( other, IsSent: true, series, worth.Count, q.TimestampUtc.ToString("HHmm"), q.Call, q.ReceivedNumber, q.SentNumber))]; } /// What the operator has typed, read as a QTC. Null when a field is empty /// or malformed, which is what the window paints yellow. public static WaeQtc? Read( Callsign station, bool isSent, int series, int countInSeries, string time, string call, string number) { string trimmedTime = time.Trim(); string trimmedCall = call.Trim().ToUpperInvariant(); if (trimmedTime.Length != 4 || !trimmedTime.All(char.IsAsciiDigit) || !IsClockTime(trimmedTime) || trimmedCall.Length < 3 || !int.TryParse(number.Trim(), out int parsed) || parsed <= 0) { return null; } return new WaeQtc( station, isSent, series, countInSeries, trimmedTime, Callsign.Parse(trimmedCall), parsed); } /// The header a station sends in front of a series: a series number, a /// separator and how many lines follow. Anything between the two numbers /// counts as the separator, because it is copied off the air. A series /// number on its own means ten lines, which is what N1MM assumes: "If he /// only types the batch number without the forward slash and count, the /// program will assume the count is 10." public static (int Series, int Count)? ReadHeader(string text) { string[] parts = text.Trim().ToUpperInvariant().Replace("QTC", "").Trim() .Split(['/', '-', ' ', '\\'], StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0 || !int.TryParse(parts[0], out int series) || series <= 0) { return null; } if (parts.Length == 1) { return (series, WaeQtc.MostPerSeries); } return int.TryParse(parts[1], out int count) && count is > 0 and <= WaeQtc.MostPerSeries ? (series, count) : null; } /// A line of received text read as the three fields of a QTC. On RTTY the /// other station sends the whole series in one transmission and the /// operator clicks the line rather than typing it, so what arrives here is /// a printed line with whatever the receiver made of the noise around it: /// `1435-DL1AAA-042`, or the same with spaces or slashes between. /// /// The fields are taken by shape, in the order they are sent: the first /// group of digits is the time, the first thing shaped like a callsign is /// the callsign, and the last group of digits is the serial number. Null /// when the line does not hold all three. public static (string Time, string Call, string Number)? SplitLine(string text) { string[] parts = text.ToUpperInvariant() .Split(['-', '/', ' ', '\t', ',', '\\'], StringSplitOptions.RemoveEmptyEntries); string time = ""; string call = ""; string number = ""; foreach (string part in parts) { string token = part.Trim(); if (token.Length == 0) { continue; } if (token.All(char.IsAsciiDigit)) { if (time.Length == 0 && token.Length is 3 or 4) { time = token; } else if (call.Length > 0) { number = token; } continue; } if (call.Length == 0 && time.Length > 0 && LooksLikeCall(token)) { call = token; } } return time.Length > 0 && call.Length > 0 && number.Length > 0 ? (time, call, number) : null; } /// Enough of a callsign to take off the air: letters and digits, at least /// one of each, three characters or more. private static bool LooksLikeCall(string token) => token.Length is >= 3 and <= 12 && token.All(c => char.IsAsciiLetterOrDigit(c) || c == '/') && token.Any(char.IsAsciiDigit) && token.Any(char.IsAsciiLetter); /// A time typed short is filled in from the line above, which is what N1MM /// does when the cursor leaves the box: `52` under `1148` reads as `1152`. /// As many characters are taken off the front of the line above as the /// typed part is missing. A colon is dropped whatever the length. public static string FillTime(string typed, string above) { string text = typed.Trim().Replace(":", ""); string previous = above.Trim().Replace(":", ""); if (text.Length is 0 or >= 4) { return text; } int missing = 4 - text.Length; return (previous.Length >= missing ? previous[..missing] : previous) + text; } 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) => position.Session.Countries?.Find(call)?.Continent ?? ""; }