Files
Nonemm/src/Nonemm.Contests/Rules/Wae.cs
T
erikbrockoandClaude Opus 5 d4d0375f1f Get WAE ready: the score split, the Cabrillo layouts and the QTC list
The score summary now splits the way N1MM's does. `ContestLog` keeps a tally
per band, and per kind of row where the contest has kinds, and the score window
reads those instead of adding the rows up itself. Three things were wrong with
the old sum:

- The band rows counted a multiplier as one whatever the band. WAE weights it
  by band — `ScoreRow.AddContact` adds four on 3.5, three on 7 and two on 14,
  21 and 28 — so the band rows did not add up to the total.
- The band rows counted contacts left out of the claimed score.
- There was no split by kind. `CWAE.LogInfo` and `ScoreClass.Description` give
  WAE a `Q/QTC` column and a row per band and kind, so contacts, traffic sent
  and traffic received are read apart.

The Cabrillo writer now pads the record name to seven characters, which is what
`ConvertCabrilloStructStdToString` does and why: `X-QSO:` is six characters and
a space, so every record name puts the frequency in the same column. Nonemm
wrote five, and the `X-QSO:` lines added last commit did not line up with the
rest.

WAE's own layouts follow `CWAE.CabrilloString`: three spaces after the sent
serial rather than the one every other column gets, and the transmitter column
only for a multi-operator entry.

`QtcTraffic.ToSend` no longer offers a contact marked as a dupe, which is what
`DBSupport.QTCRecordsetCount` leaves out with `Trim(ContactType) = ''`.

Checked by running the program against a WAE SSB log of six contacts and a
series of ten received: the band rows read 4, 3, 2 and 2 and add up to the 11
in the total row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NZkFxHde1iifAQemFTHxf
2026-09-09 20:25:45 +00:00

204 lines
7.5 KiB
C#

using Nonemm.Core;
namespace Nonemm.Contests.Rules;
/// Worked All Europe DX, CW, SSB and RTTY. Europe works the rest of the world
/// and the rest of the world works Europe; on RTTY everybody works everybody.
///
/// Two things make it unlike the other contests here. A multiplier is worth
/// more on the low bands — four times on 80 metres, three on 40, twice on 20,
/// 15 and 10 — and the contest is half about QTC traffic: a station reports
/// contacts it has already made to the station it is working, and each line
/// reported is a point for both of them. Those lines live in the log as rows of
/// their own; see `WaeQtc`.
public sealed class Wae : Contest
{
/// The countries whose call areas count separately, so W1 and W2 are two
/// multipliers rather than one.
private static readonly IReadOnlySet<string> CallAreaCountries =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"K", "VE", "VK", "ZL", "ZS", "JA", "PY", "BY",
};
/// Asiatic Russia is split by call area too, and the multiplier is written
/// `UA` and the call area digit whatever the prefix on the air, so RM0W and
/// UA0AB are one multiplier.
private static readonly IReadOnlySet<string> AsiaticRussia =
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "UA8", "UA9", "UA0" };
private readonly ModeCategory mode;
public Wae(ModeCategory mode) => this.mode = mode;
public string Name => mode switch
{
ModeCategory.Phone => "WAESSB",
ModeCategory.Digital => "WAERTTY",
_ => "WAECW",
};
public string DisplayName => $"Worked All Europe DX {ModeLabel()}";
public string CabrilloName => mode switch
{
ModeCategory.Phone => "WAE-SSB",
ModeCategory.Digital => "WAE-RTTY",
_ => "WAE-CW",
};
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
[
new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false),
new ExchangeField("SentNR", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false),
new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number),
];
public IReadOnlyList<string> MultiplierNames => ["Mults"];
public DupeScope DupeScope => DupeScope.PerBand;
public bool HasSerialNumbers => true;
public IReadOnlyList<ModeCategory> Modes => [mode];
public bool HasQtcTraffic => true;
/// N1MM's score window gives contacts, traffic sent and traffic received a
/// row each on every band, under the heading `Q/QTC`. The value is what the
/// `Exchange1` column holds.
public string RowKindFor(Qso qso) =>
WaeQtc.From(qso) is { } qtc ? (qtc.IsSent ? WaeQtc.Sent : WaeQtc.Received) : WaeQtc.Contact;
public string SentExchangeFor(StationInfo me) => "001";
/// A QTC line counts, and so does every contact on a contest band. The
/// WARC bands are not in this contest, so a contact there is worth nothing.
public int PointsFor(QsoContext qso)
{
if (!IsWorkable(qso))
{
return 0;
}
return WaeQtc.IsQtc(qso.Qso) || WeightFor(qso.Band) > 0 ? 1 : 0;
}
public bool IsContact(Qso qso) => !WaeQtc.IsQtc(qso);
/// One multiplier per band, worth more the lower the band. For a station in
/// Europe it is the other station's country or call area; for a station
/// outside it, the European country worked.
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso)
{
int weight = WeightFor(qso.Band);
if (weight == 0 || !IsWorkable(qso))
{
return [];
}
return [new Multiplier(1, MultiplierValue(qso), qso.Band?.Name ?? "", weight)];
}
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
/// A QTC is written as its own Cabrillo record: the station that received
/// the traffic, the series, the station that sent it, and then the contact
/// being reported.
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry)
{
if (WaeQtc.From(qso) is not { } qtc)
{
return new CabrilloExchange(
[
new CabrilloField(me.Callsign, 13),
new CabrilloField(qso.SentReport, 3),
// N1MM leaves three spaces after the sent serial rather
// than the one every other column gets
new CabrilloField($"{qso.SentNumber:0000}", 6),
],
[
new CabrilloField(qso.Call.Text, 13),
new CabrilloField(qso.ReceivedReport, 3),
new CabrilloField($"{qso.ReceivedNumber:0000}", 4),
])
{
// N1MM writes the transmitter column for a multi-operator
// entry and nothing at all for a single operator
AddsTransmitter = entry.OperatorCategory == "MULTI-OP",
};
}
string receiver = qtc.IsSent ? qtc.Station.Text : me.Callsign;
string sender = qtc.IsSent ? me.Callsign : qtc.Station.Text;
return new CabrilloExchange(
[
new CabrilloField(receiver, 13),
new CabrilloField(qtc.SeriesText, 10),
new CabrilloField(sender, 13),
],
[
new CabrilloField(qtc.TimeUtc, 4),
new CabrilloField(qtc.Call.Text, 13),
new CabrilloField($"{qtc.Number:0000}", 4),
]);
}
/// On CW and SSB the contest is Europe against the rest of the world, so a
/// contact with your own side of that line is worth nothing. RTTY drops the
/// rule and everybody works everybody.
public bool IsWorkable(QsoContext qso)
{
if (mode == ModeCategory.Digital)
{
return true;
}
return qso.Me.Continent == "EU" ? qso.Continent != "EU" : qso.Continent == "EU";
}
/// 80 metres is worth four, 40 three, and 20, 15 and 10 two. Everything
/// else is not in the contest.
public static int WeightFor(Band? band) => band?.Name switch
{
"80M" => 4,
"40M" => 3,
"20M" or "15M" or "10M" => 2,
_ => 0,
};
private string MultiplierValue(QsoContext qso)
{
string prefix = qso.CountryPrefix;
if (prefix.Length == 0)
{
return "";
}
// outside Europe on CW and SSB, only European countries are worked and
// the country alone is the multiplier
if (mode != ModeCategory.Digital && qso.Me.Continent != "EU")
{
return prefix;
}
if (AsiaticRussia.Contains(prefix))
{
return $"UA{CallAreaDigit(qso) ?? '9'}";
}
return CallAreaCountries.Contains(prefix) ? CallArea(qso) : prefix;
}
/// The country prefix with the call area digit on the end: W1AW is `K1`.
private static string CallArea(QsoContext qso) =>
CallAreaDigit(qso) is { } digit ? $"{qso.CountryPrefix}{digit}" : qso.CountryPrefix;
private static char? CallAreaDigit(QsoContext qso)
{
string? wpx = qso.Qso.Call.WpxPrefix();
return wpx is { Length: > 0 } && char.IsAsciiDigit(wpx[^1]) ? wpx[^1] : null;
}
private string ModeLabel() => mode switch
{
ModeCategory.Cw => "CW",
ModeCategory.Phone => "SSB",
_ => "RTTY",
};
}