Take WAE QTC traffic down
Ctrl+Z opens the QTC window for the station being worked, which is the key N1MM puts it on, and the window is laid out the way N1MM lays it out: the header, ten lines of time, callsign and serial number, an Agn and a Cfm button beside each, Clear, Close and Cancel underneath, and the same colours — green for a line that will be saved, red for one still empty, yellow for one that cannot be read. Enter and Tab move forward and space moves within a line, so a line can be walked while listening to it. Sending, the lines are filled from the log and cannot be edited: the contacts not reported to anybody yet, oldest first, never the station being worked, and never past the ten any station may have. The rule for what has been reported is N1MM's — a contact counts as reported when a QTC row carries its call and the serial we sent it — so the pair is kept in the row the way N1MM keeps it. Which way traffic goes is not the operator's choice on CW and SSB: Europe receives and the rest of the world sends. On RTTY it goes both ways, and there the window carries a switch. That is the one piece of the workflow that is ours rather than N1MM's, which toggles a QTC mode outside the window instead. Nothing here transmits. N1MM sends the lines on CW and RTTY and plays recorded voice messages on SSB; we have no QTC message templates, no voice keyer and no digital window, so Agn, Cfm and RX Ready move the cursor and the operator sends by hand. Running it caught what the tests could not: focus set in the constructor does not stick, so the first thing typed into a freshly opened window was dropped and every field after it took the value of the one before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
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, fewer
|
||||
/// when it has already had some.
|
||||
public IReadOnlyList<WaeQtc> ToSend(Callsign other)
|
||||
{
|
||||
int room = Math.Min(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 ?? "";
|
||||
}
|
||||
Reference in New Issue
Block a user