An operator copying a header off the air often types the series number and nothing else. That read as a header the program could not parse, so the box went yellow and Close refused to log the series. N1MM fills the count in: "If he only types the batch number without the forward slash and count, the program will assume the count is 10." Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
278 lines
11 KiB
C#
278 lines
11 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 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<WaeQtc> ToSend(Callsign other, int mostPerSeries = WaeQtc.MostPerSeries)
|
|
{
|
|
int room = Math.Min(Math.Clamp(mostPerSeries, 1, WaeQtc.MostPerSeries), Remaining(other));
|
|
if (room <= 0)
|
|
{
|
|
return [];
|
|
}
|
|
List<Qso> 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 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<Qso> Reportable(Callsign other)
|
|
{
|
|
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);
|
|
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 ?? "";
|
|
}
|