From 3932f83147453fb93b6cf29886b5c4fcf2c00e25 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Mon, 31 Aug 2026 09:16:44 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD --- README.md | 2 +- docs/unfinished.md | 19 +-- src/Nonemm.App/Windows/EntryWindow.Menu.cs | 2 +- src/Nonemm.Contests/Contest.cs | 6 +- src/Nonemm.Contests/ContestRegistry.cs | 2 + src/Nonemm.Contests/N1mmContestNames.cs | 9 ++ src/Nonemm.Contests/Rules/ArrlDx.cs | 2 +- src/Nonemm.Contests/Rules/CqWorldWide.cs | 2 +- src/Nonemm.Contests/Rules/CqWpx.cs | 2 +- src/Nonemm.Contests/Rules/GeneralLogging.cs | 2 +- src/Nonemm.Contests/Rules/IaruHf.cs | 2 +- .../Rules/NorthAmericanQsoParty.cs | 2 +- src/Nonemm.Contests/Rules/OkOmDx.cs | 109 ++++++++++++++++++ src/Nonemm.Contests/Rules/RttyRoundup.cs | 2 +- src/Nonemm.Contests/Rules/Sweepstakes.cs | 2 +- src/Nonemm.Contests/Rules/Wae.cs | 2 +- src/Nonemm.Contests/Rules/Yota.cs | 70 +++++++++++ src/Nonemm.Contests/Udc/UserDefinedContest.cs | 2 +- src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs | 6 +- tests/Nonemm.Contests.Tests/OkOmDxTests.cs | 75 ++++++++++++ tests/Nonemm.Contests.Tests/TestLog.cs | 14 +++ tests/Nonemm.Contests.Tests/YotaTests.cs | 49 ++++++++ 22 files changed, 360 insertions(+), 23 deletions(-) create mode 100644 src/Nonemm.Contests/Rules/OkOmDx.cs create mode 100644 src/Nonemm.Contests/Rules/Yota.cs create mode 100644 tests/Nonemm.Contests.Tests/OkOmDxTests.cs create mode 100644 tests/Nonemm.Contests.Tests/YotaTests.cs diff --git a/README.md b/README.md index 169e136..90b32dd 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ scorer: red for a dupe, green for a new multiplier, blue for points. | | | |---|---| -| Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), WAE (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, general logging, and user-defined `.udc` contests | +| Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), WAE (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, OK/OM DX, YOTA, general logging, and user-defined `.udc` contests | | Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out | | While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file | | Windows | entry, log, check, bandmap, available mults and Qs, score summary, telnet | diff --git a/docs/unfinished.md b/docs/unfinished.md index 75b8084..02b45af 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -92,7 +92,7 @@ which is too much work per spot for the little it would add. the contest rules score it, but there is no digital window: no decoding, no transmitting, no interface to fldigi, MMTTY or similar. -**Contest coverage.** Eight families are built in, plus whatever `.udc` files +**Contest coverage.** Ten families are built in, plus whatever `.udc` files are in the user-defined folder. N1MM ships well over a hundred. Opening a log from a contest that is in neither place fails with the contest name, which is the right answer but it is still a wall. @@ -117,12 +117,17 @@ default shape. The session, off-time and band-change settings as one long session. N1MM's `.udc` format has no way to say that the exchange itself differs by the -other station's country, as the OK/OM DX contest asks a district from OK and OM -stations and a serial number from everyone else. That one needs code, as it -does in N1MM, and `Contest.SkipsField` is where it goes. A contest scored by -what this station sends — YOTA counts the sent exchange — needs code too, and -reads `QsoContext.Entry`, which carries the entry categories and the sent -exchange. +other station's country. OK/OM DX is written in code for that reason — it asks +a county from OK and OM stations and a serial number from everyone else — and +`Contest.SkipsField` steps over the box that does not apply. The same is true +of REF, ARRL 10M, the Ukrainian DX contest and the Russian DX contest, which +are not written yet. + +What is left in the station's own logs, in the order it works them: IOTA, EU +DXC, CQ 160, XE RTTY, REF, SARTG, BARTG, Field Day Region 1, Oceania, All +Asian, RDAC and ARRL 10M. Everything but IOTA, REF and ARRL 10M is an exchange +we already store — a serial, an age, a zone or a district — so a `.udc` file +covers it. ## Known rough edges diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs index c12a336..880ee0d 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs @@ -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, diff --git a/src/Nonemm.Contests/Contest.cs b/src/Nonemm.Contests/Contest.cs index 7eb42ca..883c0de 100644 --- a/src/Nonemm.Contests/Contest.cs +++ b/src/Nonemm.Contests/Contest.cs @@ -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); } diff --git a/src/Nonemm.Contests/ContestRegistry.cs b/src/Nonemm.Contests/ContestRegistry.cs index 61af224..ef41de1 100644 --- a/src/Nonemm.Contests/ContestRegistry.cs +++ b/src/Nonemm.Contests/ContestRegistry.cs @@ -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()), ]; diff --git a/src/Nonemm.Contests/N1mmContestNames.cs b/src/Nonemm.Contests/N1mmContestNames.cs index 5ec1e44..d0ffbab 100644 --- a/src/Nonemm.Contests/N1mmContestNames.cs +++ b/src/Nonemm.Contests/N1mmContestNames.cs @@ -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 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) diff --git a/src/Nonemm.Contests/Rules/ArrlDx.cs b/src/Nonemm.Contests/Rules/ArrlDx.cs index 49fc1dd..8bc0115 100644 --- a/src/Nonemm.Contests/Rules/ArrlDx.cs +++ b/src/Nonemm.Contests/Rules/ArrlDx.cs @@ -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), diff --git a/src/Nonemm.Contests/Rules/CqWorldWide.cs b/src/Nonemm.Contests/Rules/CqWorldWide.cs index bdeb6e9..f5694f0 100644 --- a/src/Nonemm.Contests/Rules/CqWorldWide.cs +++ b/src/Nonemm.Contests/Rules/CqWorldWide.cs @@ -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), diff --git a/src/Nonemm.Contests/Rules/CqWpx.cs b/src/Nonemm.Contests/Rules/CqWpx.cs index eb73a66..23a9e5b 100644 --- a/src/Nonemm.Contests/Rules/CqWpx.cs +++ b/src/Nonemm.Contests/Rules/CqWpx.cs @@ -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), diff --git a/src/Nonemm.Contests/Rules/GeneralLogging.cs b/src/Nonemm.Contests/Rules/GeneralLogging.cs index 572a40e..e17e8bb 100644 --- a/src/Nonemm.Contests/Rules/GeneralLogging.cs +++ b/src/Nonemm.Contests/Rules/GeneralLogging.cs @@ -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)]); diff --git a/src/Nonemm.Contests/Rules/IaruHf.cs b/src/Nonemm.Contests/Rules/IaruHf.cs index be99a74..5bc1588 100644 --- a/src/Nonemm.Contests/Rules/IaruHf.cs +++ b/src/Nonemm.Contests/Rules/IaruHf.cs @@ -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), diff --git a/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs b/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs index 5874906..5859da9 100644 --- a/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs +++ b/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs @@ -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), diff --git a/src/Nonemm.Contests/Rules/OkOmDx.cs b/src/Nonemm.Contests/Rules/OkOmDx.cs new file mode 100644 index 0000000..e6f29ef --- /dev/null +++ b/src/Nonemm.Contests/Rules/OkOmDx.cs @@ -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 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 MultiplierNames => ["Countries", "Counties"]; + + public DupeScope DupeScope => DupeScope.PerBand; + + public bool HasSerialNumbers => true; + + public IReadOnlyList 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 MultipliersFor(QsoContext qso) + { + string band = qso.Band?.Name ?? ""; + List 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"; +} diff --git a/src/Nonemm.Contests/Rules/RttyRoundup.cs b/src/Nonemm.Contests/Rules/RttyRoundup.cs index 15bffbb..01ae5eb 100644 --- a/src/Nonemm.Contests/Rules/RttyRoundup.cs +++ b/src/Nonemm.Contests/Rules/RttyRoundup.cs @@ -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), diff --git a/src/Nonemm.Contests/Rules/Sweepstakes.cs b/src/Nonemm.Contests/Rules/Sweepstakes.cs index 3154506..4429126 100644 --- a/src/Nonemm.Contests/Rules/Sweepstakes.cs +++ b/src/Nonemm.Contests/Rules/Sweepstakes.cs @@ -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), diff --git a/src/Nonemm.Contests/Rules/Wae.cs b/src/Nonemm.Contests/Rules/Wae.cs index bd85c59..440c556 100644 --- a/src/Nonemm.Contests/Rules/Wae.cs +++ b/src/Nonemm.Contests/Rules/Wae.cs @@ -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) { diff --git a/src/Nonemm.Contests/Rules/Yota.cs b/src/Nonemm.Contests/Rules/Yota.cs new file mode 100644 index 0000000..db5140c --- /dev/null +++ b/src/Nonemm.Contests/Rules/Yota.cs @@ -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 ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Age", ExchangeSlot.Exchange1, ExchangeFieldKind.Number) { Width = 3 }, + ]; + + public IReadOnlyList MultiplierNames => ["Ages"]; + + public DupeScope DupeScope => DupeScope.PerBandAndMode; + + public bool HasSerialNumbers => false; + + public IReadOnlyList 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 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; +} diff --git a/src/Nonemm.Contests/Udc/UserDefinedContest.cs b/src/Nonemm.Contests/Udc/UserDefinedContest.cs index c7c857a..1e991b9 100644 --- a/src/Nonemm.Contests/Udc/UserDefinedContest.cs +++ b/src/Nonemm.Contests/Udc/UserDefinedContest.cs @@ -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), diff --git a/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs b/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs index dfb57d9..3e3708f 100644 --- a/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs +++ b/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs @@ -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 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(' '); diff --git a/tests/Nonemm.Contests.Tests/OkOmDxTests.cs b/tests/Nonemm.Contests.Tests/OkOmDxTests.cs new file mode 100644 index 0000000..8c55a24 --- /dev/null +++ b/tests/Nonemm.Contests.Tests/OkOmDxTests.cs @@ -0,0 +1,75 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; +using Nonemm.Core.Country; + +namespace Nonemm.Contests.Tests; + +public class OkOmDxTests +{ + private static readonly Contest Ssb = new OkOmDx(ModeCategory.Phone); + + private static ContestLog LogFor(StationInfo me) => new(Ssb, me, TestLog.CountryFile); + + private static Qso FromOkOm(string call, string county, double kilohertz = 14_025) => + TestLog.Contact(call, kilohertz, mode: Modes.Usb, section: county); + + private static Qso FromDx(string call, string number, double kilohertz = 14_025) => + TestLog.Contact(call, kilohertz, mode: Modes.Usb, exchange: number); + + [Theory] + [InlineData("OM3XYZ", 2)] + [InlineData("OK1XYZ", 3)] + public void AtHomeCzechiaAndSlovakiaAreTwoCountries(string call, int expected) => + Assert.Equal(expected, LogFor(TestLog.Slovakia).Judge(FromOkOm(call, "NOV")).Points); + + [Theory] + [InlineData("IK2XYZ", 3)] + [InlineData("JA1XYZ", 5)] + public void TheRestOfTheWorldIsScoredByContinent(string call, int expected) => + Assert.Equal(expected, LogFor(TestLog.Slovakia).Judge(FromDx(call, "014")).Points); + + [Theory] + [InlineData("OM3XYZ", 10)] + [InlineData("DL9XYZ", 1)] + [InlineData("IK2XYZ", 3)] + [InlineData("JA1XYZ", 5)] + public void AStationOutsideScoresTenForAnOkOmContact(string call, int expected) => + Assert.Equal(expected, LogFor(TestLog.Germany).Judge(FromOkOm(call, "NOV")).Points); + + [Fact] + public void CountriesAndCountiesBothCountOncePerBand() + { + ContestLog log = LogFor(TestLog.Slovakia); + Assert.Equal( + [1, 2], + log.Judge(FromOkOm("OM3XYZ", "NOV")).NewMultipliers.Select(m => m.Index).ToList()); + log.Add(FromOkOm("OM3XYZ", "NOV")); + Assert.Empty(log.Judge(FromOkOm("OM4XYZ", "NOV")).NewMultipliers); + Assert.Single(log.Judge(FromOkOm("OM4XYZ", "PAR")).NewMultipliers); + } + + [Fact] + public void AStationOutsideOkOmBringsItsCountryAndNoCounty() => + Assert.Equal( + [1], + LogFor(TestLog.Slovakia).Judge(FromDx("IK2XYZ", "014")).NewMultipliers + .Select(m => m.Index) + .ToList()); + + /// The entry window walks the county box for an OK or OM station and the + /// serial box for everyone else. + [Fact] + public void OnlyOneOfTheTwoBoxesIsWalked() + { + IReadOnlyList fields = Ssb.ExchangeFieldsFor(TestLog.Slovakia); + CountryLookup? okom = TestLog.CountryFile.Find("OM3XYZ"); + CountryLookup? dx = TestLog.CountryFile.Find("IK2XYZ"); + + Assert.Equal( + [ExchangeSlot.ReceivedReport, ExchangeSlot.Exchange1], + fields.Where(f => !Ssb.SkipsField(f, dx)).Select(f => f.Slot).ToList()); + Assert.Equal( + [ExchangeSlot.ReceivedReport, ExchangeSlot.Section], + fields.Where(f => !Ssb.SkipsField(f, okom)).Select(f => f.Slot).ToList()); + } +} diff --git a/tests/Nonemm.Contests.Tests/TestLog.cs b/tests/Nonemm.Contests.Tests/TestLog.cs index cd83bc9..cb9076e 100644 --- a/tests/Nonemm.Contests.Tests/TestLog.cs +++ b/tests/Nonemm.Contests.Tests/TestLog.cs @@ -22,6 +22,10 @@ public static class TestLog PY,PP; Asiatic Russia: 17: 30: AS: 55.88: -84.10: -7.0: UA9: RM8,RM9,RM0,UA8,UA9,UA0,R8,R9,R0; + Slovak Republic: 15: 28: EU: 48.67: -19.50: -1.0: OM: + OM; + Czech Republic: 15: 28: EU: 49.80: -15.47: -1.0: OK: + OK,OL; """; public static readonly CountryFile CountryFile = CountryFile.Parse(Countries); @@ -46,6 +50,16 @@ public static class TestLog ArrlSection = "CT", }; + public static readonly StationInfo Slovakia = new() + { + Callsign = "OM5M", + CqZone = 15, + ItuZone = 28, + Continent = "EU", + CountryPrefix = "OM", + County = "BRA", + }; + public static Qso Contact( string call, double kilohertz = 14_025, diff --git a/tests/Nonemm.Contests.Tests/YotaTests.cs b/tests/Nonemm.Contests.Tests/YotaTests.cs new file mode 100644 index 0000000..e441bb9 --- /dev/null +++ b/tests/Nonemm.Contests.Tests/YotaTests.cs @@ -0,0 +1,49 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +public class YotaTests +{ + private static ContestLog LogFor(StationInfo me) => new(new Yota(), me, TestLog.CountryFile); + + private static Qso Contact(string call, string age, double kilohertz = 14_025, Mode? mode = null) => + TestLog.Contact(call, kilohertz, mode: mode, exchange: age); + + [Theory] + [InlineData("9", 13)] + [InlineData("12", 13)] + [InlineData("13", 12)] + [InlineData("16", 12)] + [InlineData("17", 11)] + [InlineData("21", 11)] + [InlineData("22", 10)] + [InlineData("25", 10)] + [InlineData("26", 1)] + public void AYoungOperatorIsWorthMore(string age, int expected) => + Assert.Equal(expected, LogFor(TestLog.Germany).Judge(Contact("IK2XYZ", age)).Points); + + /// The age pays the same from anywhere; only the contacts with everyone + /// else are scored by continent. + [Fact] + public void AgeBeatsTheContinent() + { + ContestLog log = LogFor(TestLog.Germany); + Assert.Equal(13, log.Judge(Contact("JA1XYZ", "11")).Points); + Assert.Equal(3, log.Judge(Contact("JA1XYZ", "44")).Points); + Assert.Equal(1, log.Judge(Contact("IK2XYZ", "44")).Points); + } + + [Fact] + public void AnAgeCountsOncePerBandWhateverTheMode() + { + ContestLog log = LogFor(TestLog.Germany); + log.Add(Contact("IK2XYZ", "19")); + Assert.Empty(log.Judge(Contact("DL9XYZ", "19", mode: Modes.Usb)).NewMultipliers); + Assert.Single(log.Judge(Contact("DL9XYZ", "19", 7_025)).NewMultipliers); + } + + [Fact] + public void AnExchangeThatIsNoAgeBringsNoMultiplier() => + Assert.Empty(LogFor(TestLog.Germany).Judge(Contact("IK2XYZ", "")).NewMultipliers); +}