Add the YOTA and OK/OM DX contests

Both are in the station's log and neither could be opened. The rules are
written from the published ones and checked by replaying the log.

YOTA scores a contact by the age the other operator sends: 13 points down to 10
for anyone 25 or under, whatever the continent, and 1 point or 3 for everyone
else. Every age worked on a band is a multiplier. The 2024, 2025 and 2026 logs
come out to the contact.

OK/OM DX is the first contest whose exchange differs by the other station's
country: a county from OK and OM stations, a serial number from everyone else,
in the two columns N1MM keeps them in, and `SkipsField` walks the entry window
past the box that does not apply. Points depend on which side the operator is
on, and Czechia and Slovakia count as the two countries they are. The 2023 log
comes out to the contact.

The Cabrillo exchange now gets the entry, because YOTA sends an age that only
the contest setup knows.

Two differences are left in the log, both from the .udc files the operator
used: their YOTA file only lists ages 7 to 25, so a younger operator scored as
an adult, and one OK/OM log was made with the file for stations outside OK and
OM while signing an OM call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-31 09:16:44 +00:00
parent 5c0ca558c1
commit 3932f83147
22 changed files with 360 additions and 23 deletions

View File

@@ -107,7 +107,7 @@ public sealed partial class EntryWindow
{
return;
}
CabrilloWriter writer = new(Logging.Contest, Logging.Me);
CabrilloWriter writer = new(Logging.Contest, Logging.Me, Logging.Instance.Entry);
CabrilloHeader header = new()
{
Contest = Logging.Contest.CabrilloName,

View File

@@ -70,6 +70,8 @@ public interface Contest
/// the whole score by the power category the operator entered in.
int TotalScore(ScoreTally tally, ContestEntry entry);
/// The exchange columns of this contact's Cabrillo line.
CabrilloExchange CabrilloExchange(Qso qso, StationInfo me);
/// The exchange columns of this contact's Cabrillo line. The entry is here
/// because the sent side can be what the operator typed into the contest
/// setup rather than anything the station record holds.
CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry);
}

View File

@@ -18,6 +18,8 @@ public sealed class ContestRegistry
new("WAE", "Worked All Europe DX", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new Wae(m)),
new("ARRLRTTY", "ARRL RTTY Roundup", [ModeCategory.Digital], _ => new RttyRoundup()),
new("NAQP", "North American QSO Party", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new NorthAmericanQsoParty(m)),
new("OKOMDX", "Czech and Slovak Republics DX", [ModeCategory.Cw, ModeCategory.Phone], m => new OkOmDx(m)),
new("YOTA", "YOTA Contest", [ModeCategory.Cw, ModeCategory.Phone], _ => new Yota()),
new("DX", "General logging", [], _ => new GeneralLogging()),
];

View File

@@ -6,6 +6,11 @@ namespace Nonemm.Contests;
/// never plain `CQWW`. Our registry is keyed by the family, so a log written by
/// N1MM has to have its contest name turned back into a family and a mode
/// before the rules can be built.
///
/// The `.udc` files people use for OK/OM DX name it per mode as well, and one
/// file per side of the contest — `OKOMDXS_DX` is the one a station outside OK
/// and OM runs. Both sides are the same rules here, so both names resolve to
/// the same contest.
public static class N1mmContestNames
{
private static readonly Dictionary<string, (string Family, ModeCategory Mode)> Known =
@@ -27,6 +32,10 @@ public static class N1mmContestNames
["NAQPCW"] = ("NAQP", ModeCategory.Cw),
["NAQPSSB"] = ("NAQP", ModeCategory.Phone),
["NAQPRTTY"] = ("NAQP", ModeCategory.Digital),
["OKOMDXC"] = ("OKOMDX", ModeCategory.Cw),
["OKOMDXS"] = ("OKOMDX", ModeCategory.Phone),
["OKOMDXC_DX"] = ("OKOMDX", ModeCategory.Cw),
["OKOMDXS_DX"] = ("OKOMDX", ModeCategory.Phone),
};
public static bool TryResolve(string name, out string family, out ModeCategory mode)

View File

@@ -64,7 +64,7 @@ public sealed class ArrlDx : Contest
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) =>
new(
[
new CabrilloField(me.Callsign, 13),

View File

@@ -113,7 +113,7 @@ public sealed class CqWorldWide : Contest
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => IsRtty
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => IsRtty
? new CabrilloExchange(
[
new CabrilloField(me.Callsign, 13),

View File

@@ -76,7 +76,7 @@ public sealed class CqWpx : Contest
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) =>
new(
[
new CabrilloField(me.Callsign, 13),

View File

@@ -37,7 +37,7 @@ public sealed class GeneralLogging : Contest
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Qsos;
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) =>
new(
[new CabrilloField(me.Callsign, 13), new CabrilloField(qso.SentReport, 3)],
[new CabrilloField(qso.Call.Text, 13), new CabrilloField(qso.ReceivedReport, 3)]);

View File

@@ -60,7 +60,7 @@ public sealed class IaruHf : Contest
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) =>
new(
[
new CabrilloField(me.Callsign, 13),

View File

@@ -56,7 +56,7 @@ public sealed class NorthAmericanQsoParty : Contest
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) =>
new(
[
new CabrilloField(me.Callsign, 13),

View File

@@ -0,0 +1,109 @@
using Nonemm.Core;
using Nonemm.Core.Country;
namespace Nonemm.Contests.Rules;
/// OK/OM DX. An OK or OM station sends a county code and everyone else a
/// serial number, and which side the operator is on decides what a contact
/// scores.
///
/// The received exchange lands in the section column for an OK or OM station
/// and in the exchange column for everyone else, which is where N1MM keeps
/// each of them.
public sealed class OkOmDx : Contest
{
private readonly ModeCategory mode;
public OkOmDx(ModeCategory mode) => this.mode = mode;
public string Name => "OKOMDX";
public string DisplayName => "Czech and Slovak Republics DX";
public string CabrilloName => "OK-OM-DX";
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
[
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
new ExchangeField("County", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 4 },
new ExchangeField("Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Number),
];
/// An OK or OM station sends a county and no serial; everyone else the
/// other way round, so one of the two boxes is stepped over.
public bool SkipsField(ExchangeField field, CountryLookup? their) =>
field.Slot switch
{
ExchangeSlot.Section => their is not null && !IsOkOm(their.Entity.PrimaryPrefix),
ExchangeSlot.Exchange1 => their is not null && IsOkOm(their.Entity.PrimaryPrefix),
_ => false,
};
public IReadOnlyList<string> MultiplierNames => ["Countries", "Counties"];
public DupeScope DupeScope => DupeScope.PerBand;
public bool HasSerialNumbers => true;
public IReadOnlyList<ModeCategory> Modes => [mode];
public string SentExchangeFor(StationInfo me) => IsOkOm(me.CountryPrefix) ? me.County : "001";
/// A maritime mobile is 5 points to either side. An OK or OM operator gets
/// 2 points at home, 3 elsewhere on the continent and 5 from another —
/// Czechia and Slovakia are two countries, so they are 3 points to each
/// other. Everyone else gets 10 points for an OK or OM station, 1 at home,
/// 3 on their own continent and 5 from another.
public int PointsFor(QsoContext qso)
{
if (qso.Qso.Call.IsMaritimeMobile)
{
return 5;
}
if (!IsOkOm(qso.Me.CountryPrefix) && IsOkOm(qso.CountryPrefix))
{
return 10;
}
int home = IsOkOm(qso.Me.CountryPrefix) ? 2 : 1;
return qso.IsSameCountry ? home : qso.IsSameContinent ? 3 : 5;
}
/// Both sides count the same two, once per band each: every country, and
/// every OK or OM county. N1MM's own class counts prefixes for an OK or OM
/// operator, which was the rule before the counties came in.
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso)
{
string band = qso.Band?.Name ?? "";
List<Multiplier> found = [];
if (qso.CountryPrefix.Length > 0)
{
found.Add(new Multiplier(1, qso.CountryPrefix, band));
}
string county = County(qso.Qso);
if (IsOkOm(qso.CountryPrefix) && county.Length > 0)
{
found.Add(new Multiplier(2, county, band));
}
return found;
}
public int TotalScore(ScoreTally tally, ContestEntry entry) =>
tally.Points * tally.TotalMultipliers;
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) =>
new(
[
new CabrilloField(me.Callsign, 13),
new CabrilloField(qso.SentReport, 3),
new CabrilloField(IsOkOm(me.CountryPrefix) ? me.County : $"{qso.SentNumber:000}", 6),
],
[
new CabrilloField(qso.Call.Text, 13),
new CabrilloField(qso.ReceivedReport, 3),
new CabrilloField(County(qso).Length > 0 ? County(qso) : qso.Exchange1, 6),
]);
private static string County(Qso qso) => qso.Section.Trim().ToUpperInvariant();
private static bool IsOkOm(string countryPrefix) => countryPrefix is "OK" or "OM";
}

View File

@@ -46,7 +46,7 @@ public sealed class RttyRoundup : Contest
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) =>
new(
[
new CabrilloField(me.Callsign, 13),

View File

@@ -48,7 +48,7 @@ public sealed class Sweepstakes : Contest
/// Sweepstakes puts the callsign after the serial number and precedence
/// rather than first, which is why the writer takes the whole column list.
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) =>
new(
[
new CabrilloField($"{qso.SentNumber}", 4),

View File

@@ -96,7 +96,7 @@ public sealed class Wae : Contest
/// 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)
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry)
{
if (WaeQtc.From(qso) is not { } qtc)
{

View File

@@ -0,0 +1,70 @@
using Nonemm.Core;
namespace Nonemm.Contests.Rules;
/// The YOTA contest. The exchange is the operator's age, a contact with a
/// young operator is worth more, and every age worked on a band is a
/// multiplier.
///
/// N1MM has no class for this one — it is logged there through a `.udc` file,
/// which is why the contest name is plain `YOTA`.
public sealed class Yota : Contest
{
public string Name => "YOTA";
public string DisplayName => "YOTA Contest";
public string CabrilloName => "YOTA";
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
[
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
new ExchangeField("Age", ExchangeSlot.Exchange1, ExchangeFieldKind.Number) { Width = 3 },
];
public IReadOnlyList<string> MultiplierNames => ["Ages"];
public DupeScope DupeScope => DupeScope.PerBandAndMode;
public bool HasSerialNumbers => false;
public IReadOnlyList<ModeCategory> Modes => [ModeCategory.Cw, ModeCategory.Phone];
/// The operator's own age, which the contest setup asks for.
public string SentExchangeFor(StationInfo me) => "";
/// A contact with an operator of 25 or under is worth 10 to 13 points by
/// age, whatever the continent. Everyone else is worth 1 point on your own
/// continent and 3 from another.
public int PointsFor(QsoContext qso) => Age(qso) switch
{
null or > 25 => qso.IsSameContinent ? 1 : 3,
<= 12 => 13,
<= 16 => 12,
<= 21 => 11,
_ => 10,
};
/// Each age worked on a band, whatever the mode.
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso) =>
Age(qso) is { } age ? [new Multiplier(1, age.ToString(), qso.Band?.Name ?? "")] : [];
public int TotalScore(ScoreTally tally, ContestEntry entry) =>
tally.Points * tally.TotalMultipliers;
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) =>
new(
[
new CabrilloField(me.Callsign, 13),
new CabrilloField(qso.SentReport, 3),
new CabrilloField(entry.SentExchange, 3),
],
[
new CabrilloField(qso.Call.Text, 13),
new CabrilloField(qso.ReceivedReport, 3),
new CabrilloField(qso.Exchange1, 3),
]);
private static int? Age(QsoContext qso) =>
int.TryParse(qso.Qso.Exchange1.Trim(), out int age) && age > 0 ? age : null;
}

View File

@@ -115,7 +115,7 @@ public sealed class UserDefinedContest : Contest
return (int)Math.Round(total * pointsMultiplier.ForEntry(entry));
}
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) =>
new(
[
new CabrilloField(me.Callsign, 13),

View File

@@ -11,11 +11,13 @@ public sealed class CabrilloWriter
{
private readonly Contest contest;
private readonly StationInfo me;
private readonly ContestEntry entry;
public CabrilloWriter(Contest contest, StationInfo me)
public CabrilloWriter(Contest contest, StationInfo me, ContestEntry? entry = null)
{
this.contest = contest;
this.me = me;
this.entry = entry ?? new ContestEntry();
}
public string Write(CabrilloHeader header, IEnumerable<Qso> qsos)
@@ -62,7 +64,7 @@ public sealed class CabrilloWriter
public string QsoLine(Qso qso)
{
CabrilloExchange exchange = contest.CabrilloExchange(qso, me);
CabrilloExchange exchange = contest.CabrilloExchange(qso, me, entry);
StringBuilder line = new(contest.IsContact(qso) ? "QSO: " : "QTC: ");
line.Append(CabrilloBands.Designator(qso.Frequency).PadLeft(5)).Append(' ');
line.Append(qso.Mode.CabrilloCode.PadRight(2)).Append(' ');