Files
Nonemm/src/Nonemm.Session/QtcTraffic.cs
T
erikbrockoandClaude Opus 5 648da1918a Put the QTC window's settings on its right-click menu
N1MM keeps them on a Setup tab inside the window; here they are a dialog behind
a right-click, which suits a window this size better. Four of them, with N1MM's
own defaults: how many QTCs a series carries, whether the RX Ready and Cfm steps
are stopped at on the way through, and whether Hdr Agn and Agn clear what they
are asking for again.

Each one changes what the window does. The series count is passed to the search
for contacts worth reporting, the two skip settings are the focus order, and the
two clear settings are what the Agn buttons do. The Cfm buttons also disappear
when a RTTY station is reading its own log out, which is what N1MM does with
them.

N1MM's remaining QTC settings are all about putting traffic on the air — the CW
messages for Agn and TU, the field spacing, the four SSB recordings, the RTTY
templates — and nothing here transmits, so they are left out rather than shown
as settings that do nothing. The dialog says so where the operator will look.

The right-click menu needed a background on the panel to come up anywhere but
over a control: a panel with no background is not hit-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 00:10:39 +00:00

186 lines
6.7 KiB
C#

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<WaeQtc> ToSend(Callsign other, int mostPerSeries = WaeQtc.MostPerSeries)
{
int room = Math.Min(Math.Clamp(mostPerSeries, 1, WaeQtc.MostPerSeries), Remaining(other));
if (room <= 0)
{
return [];
}
HashSet<string> reported = position.Log.Qsos
.Where(q => q.Exchange1.Equals(WaeQtc.Sent, StringComparison.OrdinalIgnoreCase))
.Select(q => ReportedKey(q.ReceivedReport, q.ReceivedNumber))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
List<Qso> 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 ?? "";
}