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 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 RadioPosition position; public QtcTraffic(RadioPosition 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)); /// 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. 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 []; } 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.Call.Text.Equals(other.Text, StringComparison.OrdinalIgnoreCase)) .Where(q => !reported.Contains(ReportedKey(q.Call.Text, q.SentNumber))) .OrderBy(q => q.TimestampUtc) .Take(room) .ToList(); 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. public static (int Series, int Count)? ReadHeader(string text) { string[] parts = text.Trim().ToUpperInvariant().Replace("QTC", "").Trim() .Split(['/', '-', ' ', '\\'], StringSplitOptions.RemoveEmptyEntries); return parts.Length >= 2 && int.TryParse(parts[0], out int series) && int.TryParse(parts[1], out int count) && series > 0 && count is > 0 and <= WaeQtc.MostPerSeries ? (series, count) : null; } private static bool IsClockTime(string text) => int.Parse(text[..2]) < 24 && int.Parse(text[2..]) < 60; private static string ReportedKey(string call, int number) => $"{call}|{number}"; private string ContinentOf(Callsign call) => position.Session.Countries?.Find(call)?.Continent ?? ""; }