From 264036f0f2551d4347147f28f96f78e9444c755a Mon Sep 17 00:00:00 2001 From: ericek111 Date: Mon, 31 Aug 2026 09:43:03 +0000 Subject: [PATCH] Add the twelve contests left in the station's log REF, the Ukrainian DX contest, the BARTG Sprint, ARRL 10M, Russian DX RTTY, RDAC, YO DX HF, Oceania DX, IARU Region 1 Field Day, SARTG RTTY, All Asian and SA 10. Every contest name in the log now opens, and every one of these but Field Day scores its log contact for contact as N1MM did. Most are written from N1MM's own class and checked against the log. Three things the log settled that the class does not say plainly: ARRL 10M counts its multipliers once per mode rather than once, the BARTG Sprint counts a continent once in the contest while its countries and call areas count per band, and RDAC counts nothing but the districts and scores nothing for a station that sends none. All Asian and SA 10 have no class in N1MM, which logs them from a .udc file, so those two are written from the published rules and the log. docs/unfinished.md lists what is still not settled. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD --- README.md | 2 +- docs/unfinished.md | 29 +++-- src/Nonemm.Contests/ContestRegistry.cs | 12 ++ src/Nonemm.Contests/N1mmContestNames.cs | 8 ++ src/Nonemm.Contests/Rules/AllAsian.cs | 72 ++++++++++++ src/Nonemm.Contests/Rules/Arrl10Metre.cs | 79 +++++++++++++ src/Nonemm.Contests/Rules/BartgSprint.cs | 71 ++++++++++++ .../Rules/FieldDayRegionOne.cs | 72 ++++++++++++ src/Nonemm.Contests/Rules/FrenchRef.cs | 104 ++++++++++++++++++ src/Nonemm.Contests/Rules/OceaniaDx.cs | 75 +++++++++++++ .../Rules/RussianDistrictAward.cs | 62 +++++++++++ src/Nonemm.Contests/Rules/RussianDxRtty.cs | 71 ++++++++++++ src/Nonemm.Contests/Rules/SartgRtty.cs | 66 +++++++++++ src/Nonemm.Contests/Rules/SouthAmerica10.cs | 67 +++++++++++ src/Nonemm.Contests/Rules/UkrainianDx.cs | 89 +++++++++++++++ src/Nonemm.Contests/Rules/YoDxHf.cs | 82 ++++++++++++++ .../Nonemm.Contests.Tests/Arrl10MetreTests.cs | 38 +++++++ tests/Nonemm.Contests.Tests/FrenchRefTests.cs | 30 +++++ .../N1mmContestNamesTests.cs | 2 +- tests/Nonemm.Contests.Tests/OceaniaDxTests.cs | 29 +++++ .../RussianDistrictAwardTests.cs | 27 +++++ .../SmallContestTests.cs | 97 ++++++++++++++++ tests/Nonemm.Contests.Tests/TestLog.cs | 10 ++ .../Nonemm.Contests.Tests/UkrainianDxTests.cs | 25 +++++ 24 files changed, 1208 insertions(+), 11 deletions(-) create mode 100644 src/Nonemm.Contests/Rules/AllAsian.cs create mode 100644 src/Nonemm.Contests/Rules/Arrl10Metre.cs create mode 100644 src/Nonemm.Contests/Rules/BartgSprint.cs create mode 100644 src/Nonemm.Contests/Rules/FieldDayRegionOne.cs create mode 100644 src/Nonemm.Contests/Rules/FrenchRef.cs create mode 100644 src/Nonemm.Contests/Rules/OceaniaDx.cs create mode 100644 src/Nonemm.Contests/Rules/RussianDistrictAward.cs create mode 100644 src/Nonemm.Contests/Rules/RussianDxRtty.cs create mode 100644 src/Nonemm.Contests/Rules/SartgRtty.cs create mode 100644 src/Nonemm.Contests/Rules/SouthAmerica10.cs create mode 100644 src/Nonemm.Contests/Rules/UkrainianDx.cs create mode 100644 src/Nonemm.Contests/Rules/YoDxHf.cs create mode 100644 tests/Nonemm.Contests.Tests/Arrl10MetreTests.cs create mode 100644 tests/Nonemm.Contests.Tests/FrenchRefTests.cs create mode 100644 tests/Nonemm.Contests.Tests/OceaniaDxTests.cs create mode 100644 tests/Nonemm.Contests.Tests/RussianDistrictAwardTests.cs create mode 100644 tests/Nonemm.Contests.Tests/SmallContestTests.cs create mode 100644 tests/Nonemm.Contests.Tests/UkrainianDxTests.cs diff --git a/README.md b/README.md index 07c9bf6..adffd7b 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, OK/OM DX, YOTA, IOTA, EU DX Contest, CQ 160, Mexico RTTY, 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, IOTA, EU DX Contest, CQ 160, ARRL 10M, REF, Ukrainian DX, Russian DX RTTY, RDAC, YO DX HF, Oceania DX, All Asian, SA 10, SARTG RTTY, BARTG Sprint, Mexico RTTY, IARU Region 1 Field Day, 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 8625d98..e0fdd7f 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.** Fourteen families are built in, plus whatever `.udc` files +**Contest coverage.** Twenty-six 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. @@ -123,15 +123,26 @@ a county from OK and OM stations and a serial number from everyone else — and 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: REF, the -Ukrainian DX contest, SA-10, BARTG Sprint, ARRL 10M, Field Day Region 1, the -Russian DX contest, RDAC, All Asian, YO HF DX and Oceania. +Every contest in the station's own logs can now be opened, and all but five of +its 78 contest instances score contact for contact as N1MM did. -The EU DX Contest is written from N1MM's own class. The station's logs were -made with the `EU_DXC.udc` file instead, which scores a contact inside your own -country 1 point where the class scores 2, and counts a district code once in -the contest where the class counts it once per band. Those are the only -differences left in those two logs. +What still differs, and why: + +- **The EU DX Contest** is written from N1MM's own class. The station's logs + were made with the `EU_DXC.udc` file instead, which scores a contact inside + your own country 1 point where the class scores 2, and counts a district code + once in the contest where the class counts it once per band. +- **Field Day Region 1**: every contact in that log holds 1 point and no + multiplier, which is not what N1MM's own class works out, so the log looks + like it was never scored. The rules here follow the class: 5 points for a + portable station, 2 for one in Region 1 at home and 3 from outside it. +- **All Asian and SA 10** have no class in N1MM — it logs them from a `.udc` + file — so the points and multipliers are written from the published rules and + from the station's log. Their Cabrillo names have not been checked against a + sponsor's robot. +- **The BARTG Sprint** stores the time of the contact in the exchange column, + which N1MM fills in by itself. Nothing here fills it in yet, so a log written + here carries the serial number alone. ## Known rough edges diff --git a/src/Nonemm.Contests/ContestRegistry.cs b/src/Nonemm.Contests/ContestRegistry.cs index db62bfc..d8891f3 100644 --- a/src/Nonemm.Contests/ContestRegistry.cs +++ b/src/Nonemm.Contests/ContestRegistry.cs @@ -24,6 +24,18 @@ public sealed class ContestRegistry new("EUDXC", "EU DX Contest", [ModeCategory.Cw, ModeCategory.Phone], _ => new EuDxContest()), new("CQ160", "CQ World-Wide 160 Metre", [ModeCategory.Cw, ModeCategory.Phone], m => new CqOneSixty(m)), new("XERTTY", "Mexico RTTY Contest", [ModeCategory.Digital], _ => new MexicoRtty()), + new("REF", "French REF DX contest", [ModeCategory.Cw, ModeCategory.Phone], m => new FrenchRef(m)), + new("UKRAINDX", "Ukrainian DX", [ModeCategory.Cw, ModeCategory.Phone], _ => new UkrainianDx()), + new("BARTGRTTYS", "BARTG RTTY Sprint", [ModeCategory.Digital], _ => new BartgSprint()), + new("ARRL10M", "ARRL 10 Metre Contest", [ModeCategory.Cw, ModeCategory.Phone], _ => new Arrl10Metre()), + new("RUSDXRTTY", "Russian DX RTTY", [ModeCategory.Digital], _ => new RussianDxRtty()), + new("RDAC", "Russian District Award Contest", [ModeCategory.Cw, ModeCategory.Phone], _ => new RussianDistrictAward()), + new("YOHFDX", "YO DX HF", [ModeCategory.Cw, ModeCategory.Phone], _ => new YoDxHf()), + new("OCEANIA", "Oceania DX", [ModeCategory.Cw, ModeCategory.Phone], m => new OceaniaDx(m)), + new("FDREG1", "IARU Region 1 Field Day", [ModeCategory.Cw, ModeCategory.Phone], _ => new FieldDayRegionOne()), + new("SARTGRTTY", "SARTG World Wide RTTY", [ModeCategory.Digital], _ => new SartgRtty()), + new("ALLASIA", "All Asian DX", [ModeCategory.Cw, ModeCategory.Phone], m => new AllAsian(m)), + new("SA10", "SA 10 Metre Contest", [ModeCategory.Cw, ModeCategory.Phone], _ => new SouthAmerica10()), new("DX", "General logging", [], _ => new GeneralLogging()), ]; diff --git a/src/Nonemm.Contests/N1mmContestNames.cs b/src/Nonemm.Contests/N1mmContestNames.cs index dd8bd43..151f5be 100644 --- a/src/Nonemm.Contests/N1mmContestNames.cs +++ b/src/Nonemm.Contests/N1mmContestNames.cs @@ -39,6 +39,14 @@ public static class N1mmContestNames ["CQ160CW"] = ("CQ160", ModeCategory.Cw), ["CQ160SSB"] = ("CQ160", ModeCategory.Phone), ["EU_DXC"] = ("EUDXC", ModeCategory.Cw), + ["REFCW"] = ("REF", ModeCategory.Cw), + ["REFSSB"] = ("REF", ModeCategory.Phone), + ["OCEANIACW"] = ("OCEANIA", ModeCategory.Cw), + ["OCEANIASSB"] = ("OCEANIA", ModeCategory.Phone), + ["BARTGSRTTY"] = ("BARTGRTTYS", ModeCategory.Digital), + ["ALLASIACW"] = ("ALLASIA", ModeCategory.Cw), + ["ALLASIASSB"] = ("ALLASIA", ModeCategory.Phone), + ["SA10_DX"] = ("SA10", ModeCategory.Phone), }; public static bool TryResolve(string name, out string family, out ModeCategory mode) diff --git a/src/Nonemm.Contests/Rules/AllAsian.cs b/src/Nonemm.Contests/Rules/AllAsian.cs new file mode 100644 index 0000000..0fa4f08 --- /dev/null +++ b/src/Nonemm.Contests/Rules/AllAsian.cs @@ -0,0 +1,72 @@ +using Nonemm.Core; + +namespace Nonemm.Contests.Rules; + +/// The JARL All Asian DX contest. Only contacts with Asia count for a station +/// outside it. The exchange is the operator's age, the points go by band, and +/// each prefix worked counts once per band. +public sealed class AllAsian : Contest +{ + private readonly ModeCategory mode; + + public AllAsian(ModeCategory mode) => this.mode = mode; + + public string Name => mode == ModeCategory.Cw ? "ALLASIACW" : "ALLASIASSB"; + + public string DisplayName => $"All Asian DX {ModeLabel()}"; + + public string CabrilloName => mode == ModeCategory.Cw ? "AA-CW" : "AA-SSB"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Age", ExchangeSlot.Exchange1, ExchangeFieldKind.Number) { Width = 3 }, + ]; + + public IReadOnlyList MultiplierNames => ["Prefixes"]; + + public DupeScope DupeScope => DupeScope.PerBand; + + public bool HasSerialNumbers => false; + + public IReadOnlyList Modes => [mode]; + + public string SentExchangeFor(StationInfo me) => ""; + + public int PointsFor(QsoContext qso) => IsWorkable(qso) ? PointsOn(qso.Band) : 0; + + public IReadOnlyList MultipliersFor(QsoContext qso) => + IsWorkable(qso) && qso.Qso.Call.WpxPrefix() is { } prefix + ? [new Multiplier(1, prefix, 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), + ]); + + /// One side of every contact has to be in Asia. + private static bool IsWorkable(QsoContext qso) => + qso.Continent == "AS" || qso.Me.Continent == "AS"; + + private static int PointsOn(Band? band) => band?.Name switch + { + "160M" or "80M" => 3, + "40M" => 2, + null => 0, + _ => 1, + }; + + private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB"; +} diff --git a/src/Nonemm.Contests/Rules/Arrl10Metre.cs b/src/Nonemm.Contests/Rules/Arrl10Metre.cs new file mode 100644 index 0000000..c951eca --- /dev/null +++ b/src/Nonemm.Contests/Rules/Arrl10Metre.cs @@ -0,0 +1,79 @@ +using Nonemm.Contests.Multipliers; +using Nonemm.Core; +using Nonemm.Core.Country; + +namespace Nonemm.Contests.Rules; + +/// The ARRL 10 metre contest. A CW contact is 4 points and a phone contact 2. +/// US, Canadian and Mexican stations send a state or province, everyone else a +/// serial number, and both the states and the countries are multipliers, each +/// counted once per mode: the contest is one band, and CW and phone count +/// separately. +public sealed class Arrl10Metre : Contest +{ + public string Name => "ARRL10M"; + + public string DisplayName => "ARRL 10 Metre Contest"; + + public string CabrilloName => "ARRL-10"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("S/P", ExchangeSlot.Section, ExchangeFieldKind.UsStateOrCanadianProvince), + new ExchangeField("Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Number), + ]; + + public bool SkipsField(ExchangeField field, CountryLookup? their) => field.Slot switch + { + ExchangeSlot.Section => their is not null && !SendsState(their.Entity.PrimaryPrefix), + ExchangeSlot.Exchange1 => their is not null && SendsState(their.Entity.PrimaryPrefix), + _ => false, + }; + + public IReadOnlyList MultiplierNames => ["States", "Countries"]; + + public DupeScope DupeScope => DupeScope.PerMode; + + public bool HasSerialNumbers => true; + + public IReadOnlyList Modes => [ModeCategory.Cw, ModeCategory.Phone]; + + public string SentExchangeFor(StationInfo me) => + SendsState(me.CountryPrefix) + ? me.State.Length > 0 ? me.State : me.Province + : "001"; + + public int PointsFor(QsoContext qso) => qso.ModeCategory == ModeCategory.Cw ? 4 : 2; + + public IReadOnlyList MultipliersFor(QsoContext qso) + { + string mode = qso.ModeCategory.ToString(); + string area = StatesAndProvinces.ArrlDxArea(qso.Qso.Section); + if (StatesAndProvinces.ArrlDxMultipliers.Contains(area)) + { + return [new Multiplier(1, area, mode)]; + } + return qso.CountryPrefix.Length == 0 ? [] : [new Multiplier(2, qso.CountryPrefix, mode)]; + } + + 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(SentExchangeFor(me), 6), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField(qso.ReceivedReport, 3), + new CabrilloField(qso.Section.Length > 0 ? qso.Section : qso.Exchange1, 6), + ]); + + /// The states and provinces of the US, Canada and Mexico are the exchange; + /// the rest of the world sends a number. + private static bool SendsState(string countryPrefix) => countryPrefix is "K" or "VE" or "XE"; +} diff --git a/src/Nonemm.Contests/Rules/BartgSprint.cs b/src/Nonemm.Contests/Rules/BartgSprint.cs new file mode 100644 index 0000000..de23696 --- /dev/null +++ b/src/Nonemm.Contests/Rules/BartgSprint.cs @@ -0,0 +1,71 @@ +using Nonemm.Core; + +namespace Nonemm.Contests.Rules; + +/// The BARTG RTTY Sprint. Every contact is one point; the multipliers are the +/// countries and the US and Canadian call areas once per band, and the +/// continents once in the contest. +/// +/// The exchange is a serial number. N1MM also writes the time of the contact +/// into the exchange column, which the Cabrillo line carries. +public sealed class BartgSprint : Contest +{ + public string Name => "BARTGRTTYS"; + + public string DisplayName => "BARTG RTTY Sprint"; + + public string CabrilloName => "BARTG-SPRINT"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), + ]; + + public IReadOnlyList MultiplierNames => ["Countries", "Areas", "Continents"]; + + public DupeScope DupeScope => DupeScope.PerBand; + + public bool HasSerialNumbers => true; + + public IReadOnlyList Modes => [ModeCategory.Digital]; + + public string SentExchangeFor(StationInfo me) => "001"; + + public int PointsFor(QsoContext qso) => 1; + + 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 area = qso.Qso.Section.Trim().ToUpperInvariant(); + if (area.Length > 0) + { + found.Add(new Multiplier(2, area, band)); + } + if (qso.Continent.Length > 0) + { + found.Add(new Multiplier(3, qso.Continent, "")); + } + 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.SentNumber:000}", 4), + new CabrilloField(qso.TimestampUtc.ToString("HHmm"), 4), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField($"{qso.ReceivedNumber:000}", 4), + new CabrilloField(qso.Exchange1, 4), + ]); +} diff --git a/src/Nonemm.Contests/Rules/FieldDayRegionOne.cs b/src/Nonemm.Contests/Rules/FieldDayRegionOne.cs new file mode 100644 index 0000000..683ea06 --- /dev/null +++ b/src/Nonemm.Contests/Rules/FieldDayRegionOne.cs @@ -0,0 +1,72 @@ +using Nonemm.Core; + +namespace Nonemm.Contests.Rules; + +/// The IARU Region 1 Field Day. A portable station is worth 5 points, a station +/// in Region 1 operating from home 2, and one outside Region 1 3. The exchange +/// is a serial number and the countries are multipliers, once per band. +public sealed class FieldDayRegionOne : Contest +{ + public string Name => "FDREG1"; + + public string DisplayName => "IARU Region 1 Field Day"; + + public string CabrilloName => "FIELDDAY-REGION-1"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), + ]; + + public IReadOnlyList MultiplierNames => ["Countries"]; + + public DupeScope DupeScope => DupeScope.PerBandAndMode; + + public bool HasSerialNumbers => true; + + public IReadOnlyList Modes => [ModeCategory.Cw, ModeCategory.Phone]; + + public string SentExchangeFor(StationInfo me) => "001"; + + public int PointsFor(QsoContext qso) + { + if (IsPortable(qso.Qso.Call)) + { + return 5; + } + return IsRegionOne(qso.Continent) ? 2 : 3; + } + + public IReadOnlyList MultipliersFor(QsoContext qso) => + qso.CountryPrefix.Length == 0 + ? [] + : [new Multiplier(1, qso.CountryPrefix, 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($"{qso.SentNumber:000}", 4), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField(qso.ReceivedReport, 3), + new CabrilloField($"{qso.ReceivedNumber:000}", 4), + ]); + + /// A field day station signs portable, mobile or /A. + private static bool IsPortable(Callsign call) => + call.Text.EndsWith("/P", StringComparison.OrdinalIgnoreCase) + || call.Text.EndsWith("/M", StringComparison.OrdinalIgnoreCase) + || call.Text.EndsWith("/A", StringComparison.OrdinalIgnoreCase); + + /// Region 1 is Europe, Africa and the Middle East; the country file names + /// the first two, and Asia is split between regions, which the continent + /// alone cannot say. + private static bool IsRegionOne(string continent) => continent is "EU" or "AF"; +} diff --git a/src/Nonemm.Contests/Rules/FrenchRef.cs b/src/Nonemm.Contests/Rules/FrenchRef.cs new file mode 100644 index 0000000..28cd4c1 --- /dev/null +++ b/src/Nonemm.Contests/Rules/FrenchRef.cs @@ -0,0 +1,104 @@ +using Nonemm.Core; +using Nonemm.Core.Country; + +namespace Nonemm.Contests.Rules; + +/// The French REF DX contest, CW and phone. A French station sends its +/// department, everyone else a serial number. For a station outside France a +/// contact is 1 point on its own continent and 3 from another, and the +/// multipliers are the French departments and the French entities overseas. +public sealed class FrenchRef : Contest +{ + /// France and everything the contest counts as French: Corsica, the + /// overseas departments and territories, and the Antarctic bases. + private static readonly IReadOnlySet French = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "F", "TK", "FG", "FH", "FJ", "FM", "FP", "FS", "FW", "FY", "FK", "TX0", "FO", "FR", + "FT5X", "FT5Y", "FT5Z", "FT5W", "FT8X", "FT8Y", "FT8Z", "FT8W", "CE9", + }; + + private readonly ModeCategory mode; + + public FrenchRef(ModeCategory mode) => this.mode = mode; + + public string Name => mode == ModeCategory.Cw ? "REFCW" : "REFSSB"; + + public string DisplayName => $"French REF DX contest {ModeLabel()}"; + + public string CabrilloName => mode == ModeCategory.Cw ? "REF-CW" : "REF-SSB"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Dept", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 3 }, + new ExchangeField("Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Number), + ]; + + public bool SkipsField(ExchangeField field, CountryLookup? their) => field.Slot switch + { + ExchangeSlot.Section => their is not null && their.Entity.PrimaryPrefix != "F", + ExchangeSlot.Exchange1 => their is not null && their.Entity.PrimaryPrefix == "F", + _ => false, + }; + + public IReadOnlyList MultiplierNames => ["Mults"]; + + public DupeScope DupeScope => DupeScope.PerBand; + + public bool HasSerialNumbers => true; + + public IReadOnlyList Modes => [mode]; + + public string SentExchangeFor(StationInfo me) => + me.CountryPrefix == "F" ? me.County : "001"; + + /// A French operator gets 6 points for another French station on its own + /// continent and 15 from another, and 1 or 2 points for everyone else. + public int PointsFor(QsoContext qso) + { + if (IsFrench(qso.Me.CountryPrefix)) + { + return IsFrench(qso.CountryPrefix) + ? qso.IsSameContinent ? 6 : 15 + : qso.IsSameContinent ? 1 : 2; + } + return qso.IsSameContinent ? 1 : 3; + } + + /// A French operator counts countries; everyone else counts the French + /// departments and the French entities overseas, each once per band. + public IReadOnlyList MultipliersFor(QsoContext qso) + { + string band = qso.Band?.Name ?? ""; + if (IsFrench(qso.Me.CountryPrefix)) + { + return qso.CountryPrefix.Length == 0 ? [] : [new Multiplier(1, qso.CountryPrefix, band)]; + } + if (qso.CountryPrefix == "F") + { + string department = qso.Qso.Section.Trim().ToUpperInvariant(); + return department.Length == 0 ? [] : [new Multiplier(1, department, band)]; + } + return IsFrench(qso.CountryPrefix) ? [new Multiplier(1, qso.CountryPrefix, band)] : []; + } + + 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(me.CountryPrefix == "F" ? me.County : $"{qso.SentNumber:000}", 6), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField(qso.ReceivedReport, 3), + new CabrilloField(qso.Section.Length > 0 ? qso.Section : qso.Exchange1, 6), + ]); + + private static bool IsFrench(string countryPrefix) => French.Contains(countryPrefix); + + private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB"; +} diff --git a/src/Nonemm.Contests/Rules/OceaniaDx.cs b/src/Nonemm.Contests/Rules/OceaniaDx.cs new file mode 100644 index 0000000..98465f8 --- /dev/null +++ b/src/Nonemm.Contests/Rules/OceaniaDx.cs @@ -0,0 +1,75 @@ +using Nonemm.Core; + +namespace Nonemm.Contests.Rules; + +/// The Oceania DX contest, CW and phone. Only contacts with Oceania count for +/// a station outside it, and they are worth more the lower the band: 20 points +/// on 160 metres, 10 on 80, 5 on 40 and 1 on the higher bands. Each prefix +/// worked counts once per band. +public sealed class OceaniaDx : Contest +{ + private readonly ModeCategory mode; + + public OceaniaDx(ModeCategory mode) => this.mode = mode; + + public string Name => mode == ModeCategory.Cw ? "OceaniaCW" : "OceaniaSSB"; + + public string DisplayName => $"Oceania DX {ModeLabel()}"; + + public string CabrilloName => mode == ModeCategory.Cw ? "Oceania-DX-CW" : "Oceania-DX-SSB"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), + ]; + + public IReadOnlyList MultiplierNames => ["Prefixes"]; + + public DupeScope DupeScope => DupeScope.PerBand; + + public bool HasSerialNumbers => true; + + public IReadOnlyList Modes => [mode]; + + public string SentExchangeFor(StationInfo me) => "001"; + + public int PointsFor(QsoContext qso) => + IsWorkable(qso) ? PointsOn(qso.Band) : 0; + + public IReadOnlyList MultipliersFor(QsoContext qso) => + IsWorkable(qso) && qso.Qso.Call.WpxPrefix() is { } prefix + ? [new Multiplier(1, prefix, 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($"{qso.SentNumber:000}", 4), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField(qso.ReceivedReport, 3), + new CabrilloField($"{qso.ReceivedNumber:000}", 4), + ]); + + /// One side of every contact has to be in Oceania. + private static bool IsWorkable(QsoContext qso) => + qso.Continent == "OC" || qso.Me.Continent == "OC"; + + private static int PointsOn(Band? band) => band?.Name switch + { + "160M" => 20, + "80M" => 10, + "40M" => 5, + null => 0, + _ => 1, + }; + + private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB"; +} diff --git a/src/Nonemm.Contests/Rules/RussianDistrictAward.cs b/src/Nonemm.Contests/Rules/RussianDistrictAward.cs new file mode 100644 index 0000000..ca6e594 --- /dev/null +++ b/src/Nonemm.Contests/Rules/RussianDistrictAward.cs @@ -0,0 +1,62 @@ +using Nonemm.Core; + +namespace Nonemm.Contests.Rules; + +/// The Russian District Award contest. Only a station that sends a district +/// code counts: it is worth 10 points and its district is a multiplier once +/// per band. Everyone else is worth nothing. +public sealed class RussianDistrictAward : Contest +{ + public string Name => "RDAC"; + + public string DisplayName => "Russian District Award Contest"; + + public string CabrilloName => "RDAC"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("RDA", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 5 }, + ]; + + /// N1MM names a country multiplier for this contest and never counts one, + /// and the score summary shows both, so the districts stay in the second + /// column here too. + public IReadOnlyList MultiplierNames => ["Countries", "Districts"]; + + public DupeScope DupeScope => DupeScope.PerBandAndMode; + + public bool HasSerialNumbers => false; + + public IReadOnlyList Modes => [ModeCategory.Cw, ModeCategory.Phone]; + + public string SentExchangeFor(StationInfo me) => me.County; + + public int PointsFor(QsoContext qso) => District(qso.Qso).Length > 0 ? 10 : 0; + + public IReadOnlyList MultipliersFor(QsoContext qso) + { + string district = District(qso.Qso); + return district.Length == 0 + ? [] + : [new Multiplier(2, district, 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(me.County, 6), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField(qso.ReceivedReport, 3), + new CabrilloField(District(qso), 6), + ]); + + private static string District(Qso qso) => qso.Section.Trim().ToUpperInvariant(); +} diff --git a/src/Nonemm.Contests/Rules/RussianDxRtty.cs b/src/Nonemm.Contests/Rules/RussianDxRtty.cs new file mode 100644 index 0000000..bbb717d --- /dev/null +++ b/src/Nonemm.Contests/Rules/RussianDxRtty.cs @@ -0,0 +1,71 @@ +using Nonemm.Core; +using Nonemm.Core.Country; + +namespace Nonemm.Contests.Rules; + +/// The Russian DX RTTY contest. A Russian station sends its oblast, everyone +/// else their CQ zone. A contact on your own continent is 5 points and one +/// from another 10, and the countries and the oblasts are multipliers, each +/// once per band. +public sealed class RussianDxRtty : Contest +{ + public string Name => "RUSDXRTTY"; + + public string DisplayName => "Russian DX RTTY"; + + public string CabrilloName => "RADIO-WW-RTTY"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Oblast/Zone", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 3 }, + ]; + + public IReadOnlyList MultiplierNames => ["Countries", "Oblasts"]; + + public DupeScope DupeScope => DupeScope.PerBand; + + public bool HasSerialNumbers => false; + + public IReadOnlyList Modes => [ModeCategory.Digital]; + + public string SentExchangeFor(StationInfo me) => + IsRussian(me.CountryPrefix) ? me.County : me.CqZone.ToString(); + + public int PointsFor(QsoContext qso) => qso.IsSameContinent ? 5 : 10; + + 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 oblast = qso.Qso.Section.Trim().ToUpperInvariant(); + if (IsRussian(qso.CountryPrefix) && oblast.Length > 0) + { + found.Add(new Multiplier(2, oblast, 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(SentExchangeFor(me), 6), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField(qso.ReceivedReport, 3), + new CabrilloField(qso.Section, 6), + ]); + + private static bool IsRussian(string countryPrefix) => + countryPrefix is "UA" or "UA2" or "UA9"; +} diff --git a/src/Nonemm.Contests/Rules/SartgRtty.cs b/src/Nonemm.Contests/Rules/SartgRtty.cs new file mode 100644 index 0000000..0b7bfd8 --- /dev/null +++ b/src/Nonemm.Contests/Rules/SartgRtty.cs @@ -0,0 +1,66 @@ +using Nonemm.Core; + +namespace Nonemm.Contests.Rules; + +/// The SARTG World Wide RTTY contest. A contact inside your own country is 5 +/// points, one on your own continent 10 and one from another 15. The countries +/// count once per band, and so do the US, Canadian and Japanese call areas. +public sealed class SartgRtty : Contest +{ + public string Name => "SARTGRTTY"; + + public string DisplayName => "SARTG World Wide RTTY"; + + public string CabrilloName => "SARTG-RTTY"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), + ]; + + public IReadOnlyList MultiplierNames => ["Countries", "Areas"]; + + public DupeScope DupeScope => DupeScope.PerBand; + + public bool HasSerialNumbers => true; + + public IReadOnlyList Modes => [ModeCategory.Digital]; + + public string SentExchangeFor(StationInfo me) => "001"; + + public int PointsFor(QsoContext qso) => + qso.IsSameCountry ? 5 : qso.IsSameContinent ? 10 : 15; + + 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 area = qso.Qso.Section.Trim().ToUpperInvariant(); + if (area.Length > 0) + { + found.Add(new Multiplier(2, area, 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($"{qso.SentNumber:000}", 4), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField(qso.ReceivedReport, 3), + new CabrilloField($"{qso.ReceivedNumber:000}", 4), + ]); +} diff --git a/src/Nonemm.Contests/Rules/SouthAmerica10.cs b/src/Nonemm.Contests/Rules/SouthAmerica10.cs new file mode 100644 index 0000000..e3beab1 --- /dev/null +++ b/src/Nonemm.Contests/Rules/SouthAmerica10.cs @@ -0,0 +1,67 @@ +using Nonemm.Core; + +namespace Nonemm.Contests.Rules; + +/// The SA 10 metre contest. A South American station is worth 4 points and +/// everyone else 2. The exchange is a CQ zone, and both the prefixes and the +/// zones are multipliers. One band, so nothing is counted per band. +/// +/// N1MM logs this one through a `.udc` file, so the Cabrillo name here is the +/// contest name and has not been checked against the sponsor's robot. +public sealed class SouthAmerica10 : Contest +{ + public string Name => "SA10"; + + public string DisplayName => "SA 10 Metre Contest"; + + public string CabrilloName => "SA10"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Zone", ExchangeSlot.Section, ExchangeFieldKind.CqZone), + ]; + + public IReadOnlyList MultiplierNames => ["Prefixes", "Zones"]; + + public DupeScope DupeScope => DupeScope.PerMode; + + public bool HasSerialNumbers => false; + + public IReadOnlyList Modes => [ModeCategory.Cw, ModeCategory.Phone]; + + public string SentExchangeFor(StationInfo me) => me.CqZone.ToString(); + + public int PointsFor(QsoContext qso) => qso.Continent == "SA" ? 4 : 2; + + public IReadOnlyList MultipliersFor(QsoContext qso) + { + List found = []; + if (qso.Qso.Call.WpxPrefix() is { } prefix) + { + found.Add(new Multiplier(1, prefix, "")); + } + string zone = qso.Qso.Section.Trim(); + if (zone.Length > 0) + { + found.Add(new Multiplier(2, zone, "")); + } + 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(me.CqZone.ToString(), 3), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField(qso.ReceivedReport, 3), + new CabrilloField(qso.Section, 3), + ]); +} diff --git a/src/Nonemm.Contests/Rules/UkrainianDx.cs b/src/Nonemm.Contests/Rules/UkrainianDx.cs new file mode 100644 index 0000000..a4d083d --- /dev/null +++ b/src/Nonemm.Contests/Rules/UkrainianDx.cs @@ -0,0 +1,89 @@ +using Nonemm.Core; +using Nonemm.Core.Country; + +namespace Nonemm.Contests.Rules; + +/// The Ukrainian DX contest. A Ukrainian station sends its oblast, everyone +/// else a serial number, and a contact with Ukraine is worth 10 points to a +/// station outside it. The multipliers are the countries and the oblasts, each +/// once per band. +public sealed class UkrainianDx : Contest +{ + public string Name => "UKRAINDX"; + + public string DisplayName => "Ukrainian DX"; + + public string CabrilloName => "UKRAINIAN-DX"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Oblast", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 3 }, + new ExchangeField("Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Number), + ]; + + public bool SkipsField(ExchangeField field, CountryLookup? their) => field.Slot switch + { + ExchangeSlot.Section => their is not null && !IsUkrainian(their.Entity.PrimaryPrefix), + ExchangeSlot.Exchange1 => their is not null && IsUkrainian(their.Entity.PrimaryPrefix), + _ => false, + }; + + public IReadOnlyList MultiplierNames => ["Countries", "Oblasts"]; + + public DupeScope DupeScope => DupeScope.PerBandAndMode; + + public bool HasSerialNumbers => true; + + public IReadOnlyList Modes => [ModeCategory.Cw, ModeCategory.Phone]; + + public string SentExchangeFor(StationInfo me) => + IsUkrainian(me.CountryPrefix) ? me.County : "001"; + + public int PointsFor(QsoContext qso) + { + if (IsUkrainian(qso.CountryPrefix)) + { + return IsUkrainian(qso.Me.CountryPrefix) ? 1 : 10; + } + if (qso.IsSameCountry) + { + return 1; + } + return qso.IsSameContinent ? 2 : 3; + } + + 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 oblast = qso.Qso.Section.Trim().ToUpperInvariant(); + if (IsUkrainian(qso.CountryPrefix) && oblast.Length > 0) + { + found.Add(new Multiplier(2, oblast, 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(SentExchangeFor(me), 6), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField(qso.ReceivedReport, 3), + new CabrilloField(qso.Section.Length > 0 ? qso.Section : qso.Exchange1, 6), + ]); + + private static bool IsUkrainian(string countryPrefix) => countryPrefix == "UR"; +} diff --git a/src/Nonemm.Contests/Rules/YoDxHf.cs b/src/Nonemm.Contests/Rules/YoDxHf.cs new file mode 100644 index 0000000..2f459dd --- /dev/null +++ b/src/Nonemm.Contests/Rules/YoDxHf.cs @@ -0,0 +1,82 @@ +using Nonemm.Core; +using Nonemm.Core.Country; + +namespace Nonemm.Contests.Rules; + +/// The Romanian YO DX HF contest. A Romanian station sends its county and is +/// worth 8 points; everyone else is 1 point inside your own country, 2 on your +/// continent and 4 from another. The countries and the Romanian counties are +/// multipliers, each once per band. +public sealed class YoDxHf : Contest +{ + public string Name => "YOHFDX"; + + public string DisplayName => "YO DX HF"; + + public string CabrilloName => "YO-DX-HF"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("County", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 3 }, + new ExchangeField("Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Number), + ]; + + public bool SkipsField(ExchangeField field, CountryLookup? their) => field.Slot switch + { + ExchangeSlot.Section => their is not null && their.Entity.PrimaryPrefix != "YO", + ExchangeSlot.Exchange1 => their is not null && their.Entity.PrimaryPrefix == "YO", + _ => false, + }; + + public IReadOnlyList MultiplierNames => ["Countries", "Counties"]; + + public DupeScope DupeScope => DupeScope.PerBandAndMode; + + public bool HasSerialNumbers => true; + + public IReadOnlyList Modes => [ModeCategory.Cw, ModeCategory.Phone]; + + public string SentExchangeFor(StationInfo me) => me.CountryPrefix == "YO" ? me.County : "001"; + + public int PointsFor(QsoContext qso) + { + if (qso.CountryPrefix == "YO") + { + return 8; + } + return qso.IsSameCountry ? 1 : qso.IsSameContinent ? 2 : 4; + } + + 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 = qso.Qso.Section.Trim().ToUpperInvariant(); + if (qso.CountryPrefix == "YO" && 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(SentExchangeFor(me), 6), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField(qso.ReceivedReport, 3), + new CabrilloField(qso.Section.Length > 0 ? qso.Section : qso.Exchange1, 6), + ]); +} diff --git a/tests/Nonemm.Contests.Tests/Arrl10MetreTests.cs b/tests/Nonemm.Contests.Tests/Arrl10MetreTests.cs new file mode 100644 index 0000000..9c0d897 --- /dev/null +++ b/tests/Nonemm.Contests.Tests/Arrl10MetreTests.cs @@ -0,0 +1,38 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +public class Arrl10MetreTests +{ + private static ContestLog LogFor() => new(new Arrl10Metre(), TestLog.Germany, TestLog.CountryFile); + + private static Qso Contact(string call, Mode mode, string state = "", string number = "") => + TestLog.Contact(call, 28_025, mode: mode, section: state, exchange: number); + + [Fact] + public void CwIsWorthFourAndPhoneTwo() + { + ContestLog log = LogFor(); + Assert.Equal(4, log.Judge(Contact("K1ABC", Modes.Cw, state: "CT")).Points); + Assert.Equal(2, log.Judge(Contact("K1ABC", Modes.Usb, state: "CT")).Points); + } + + /// The contest is one band and counts its multipliers once per mode. + [Fact] + public void AMultiplierCountsOncePerMode() + { + ContestLog log = LogFor(); + log.Add(Contact("K1ABC", Modes.Cw, state: "CT")); + Assert.Empty(log.Judge(Contact("K2ABC", Modes.Cw, state: "CT")).NewMultipliers); + Assert.Single(log.Judge(Contact("K2ABC", Modes.Usb, state: "CT")).NewMultipliers); + } + + [Fact] + public void AStationWithNoStateBringsItsCountry() => + Assert.Equal( + [2], + LogFor().Judge(Contact("JA1XYZ", Modes.Cw, number: "042")).NewMultipliers + .Select(m => m.Index) + .ToList()); +} diff --git a/tests/Nonemm.Contests.Tests/FrenchRefTests.cs b/tests/Nonemm.Contests.Tests/FrenchRefTests.cs new file mode 100644 index 0000000..bf64423 --- /dev/null +++ b/tests/Nonemm.Contests.Tests/FrenchRefTests.cs @@ -0,0 +1,30 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +public class FrenchRefTests +{ + private static ContestLog LogFor() => + new(new FrenchRef(ModeCategory.Phone), TestLog.Germany, TestLog.CountryFile); + + private static Qso Contact(string call, string department = "", string number = "") => + TestLog.Contact(call, mode: Modes.Usb, section: department, exchange: number); + + [Theory] + [InlineData("F5XYZ", 1)] + [InlineData("IK2XYZ", 1)] + [InlineData("JA1XYZ", 3)] + public void AStationOutsideFranceScoresByContinent(string call, int expected) => + Assert.Equal(expected, LogFor().Judge(Contact(call, department: "03")).Points); + + /// A French department is the multiplier, and so is a French entity + /// overseas, but an ordinary DX contact is neither. + [Fact] + public void OnlyFranceBringsAMultiplier() + { + ContestLog log = LogFor(); + Assert.Equal("03", log.Judge(Contact("F5XYZ", department: "03")).NewMultipliers.Single().Value); + Assert.Empty(log.Judge(Contact("JA1XYZ", number: "001")).NewMultipliers); + } +} diff --git a/tests/Nonemm.Contests.Tests/N1mmContestNamesTests.cs b/tests/Nonemm.Contests.Tests/N1mmContestNamesTests.cs index ad7ebf6..329ea36 100644 --- a/tests/Nonemm.Contests.Tests/N1mmContestNamesTests.cs +++ b/tests/Nonemm.Contests.Tests/N1mmContestNamesTests.cs @@ -47,7 +47,7 @@ public class N1mmContestNamesTests [Fact] public void AContestNeitherProgramKnowsIsStillAnError() => - Assert.Throws(() => Registry().Create("BARTGSRTTY", ModeCategory.Digital)); + Assert.Throws(() => Registry().Create("NOSUCHCONTEST", ModeCategory.Digital)); [Fact] public void HasFindsAContestByTheNameN1mmWrote() => diff --git a/tests/Nonemm.Contests.Tests/OceaniaDxTests.cs b/tests/Nonemm.Contests.Tests/OceaniaDxTests.cs new file mode 100644 index 0000000..4d5efce --- /dev/null +++ b/tests/Nonemm.Contests.Tests/OceaniaDxTests.cs @@ -0,0 +1,29 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +public class OceaniaDxTests +{ + private static ContestLog LogFor() => + new(new OceaniaDx(ModeCategory.Phone), TestLog.Germany, TestLog.CountryFile); + + [Theory] + [InlineData(1_830, 20)] + [InlineData(3_650, 10)] + [InlineData(7_050, 5)] + [InlineData(14_150, 1)] + public void TheLowerTheBandTheMorePoints(double kilohertz, int expected) => + Assert.Equal( + expected, + LogFor().Judge(TestLog.Contact("VK4XYZ", kilohertz, mode: Modes.Usb)).Points); + + /// One side of the contact has to be in Oceania. + [Fact] + public void AContactOutsideOceaniaScoresNothing() + { + Verdict verdict = LogFor().Judge(TestLog.Contact("IK2XYZ", mode: Modes.Usb)); + Assert.Equal(0, verdict.Points); + Assert.Empty(verdict.NewMultipliers); + } +} diff --git a/tests/Nonemm.Contests.Tests/RussianDistrictAwardTests.cs b/tests/Nonemm.Contests.Tests/RussianDistrictAwardTests.cs new file mode 100644 index 0000000..6d47c27 --- /dev/null +++ b/tests/Nonemm.Contests.Tests/RussianDistrictAwardTests.cs @@ -0,0 +1,27 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +public class RussianDistrictAwardTests +{ + private static ContestLog LogFor() => + new(new RussianDistrictAward(), TestLog.Germany, TestLog.CountryFile); + + /// Only a station that sends a district counts. + [Fact] + public void ADistrictIsTenPointsAndAMultiplier() + { + Verdict verdict = LogFor().Judge(TestLog.Contact("UA3XYZ", section: "MA12")); + Assert.Equal(10, verdict.Points); + Assert.Equal("MA12", verdict.NewMultipliers.Single().Value); + } + + [Fact] + public void AStationWithNoDistrictScoresNothing() + { + Verdict verdict = LogFor().Judge(TestLog.Contact("IK2XYZ")); + Assert.Equal(0, verdict.Points); + Assert.Empty(verdict.NewMultipliers); + } +} diff --git a/tests/Nonemm.Contests.Tests/SmallContestTests.cs b/tests/Nonemm.Contests.Tests/SmallContestTests.cs new file mode 100644 index 0000000..fb53666 --- /dev/null +++ b/tests/Nonemm.Contests.Tests/SmallContestTests.cs @@ -0,0 +1,97 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +/// The contests whose rules are one table each: what a contact scores and +/// where its multiplier comes from. +public class SmallContestTests +{ + private static ContestLog LogFor(Contest contest) => + new(contest, TestLog.Germany, TestLog.CountryFile); + + [Theory] + [InlineData("DL9XYZ", 5)] + [InlineData("IK2XYZ", 10)] + [InlineData("JA1XYZ", 15)] + public void SartgScoresByCountryAndContinent(string call, int expected) => + Assert.Equal( + expected, + LogFor(new SartgRtty()).Judge(TestLog.Contact(call, mode: Modes.Rtty)).Points); + + /// The SARTG call area is a second multiplier next to the country. + [Fact] + public void SartgCountsCallAreasAsWellAsCountries() => + Assert.Equal( + [1, 2], + LogFor(new SartgRtty()) + .Judge(TestLog.Contact("K1ABC", mode: Modes.Rtty, section: "K1")) + .NewMultipliers.Select(m => m.Index).ToList()); + + [Theory] + [InlineData(14_150, 1)] + [InlineData(7_050, 2)] + [InlineData(3_650, 3)] + public void AllAsianScoresByBand(double kilohertz, int expected) => + Assert.Equal( + expected, + LogFor(new AllAsian(ModeCategory.Phone)) + .Judge(TestLog.Contact("JA1XYZ", kilohertz, mode: Modes.Usb, exchange: "46")) + .Points); + + [Fact] + public void AllAsianCountsOnlyAsia() => + Assert.Equal( + 0, + LogFor(new AllAsian(ModeCategory.Phone)) + .Judge(TestLog.Contact("IK2XYZ", mode: Modes.Usb, exchange: "46")) + .Points); + + [Theory] + [InlineData("PY2XYZ", 4)] + [InlineData("IK2XYZ", 2)] + public void SouthAmericaTenPaysMoreForSouthAmerica(string call, int expected) => + Assert.Equal( + expected, + LogFor(new SouthAmerica10()) + .Judge(TestLog.Contact(call, 28_400, mode: Modes.Usb, section: "11")) + .Points); + + [Theory] + [InlineData("YO3XYZ", 8)] + [InlineData("DL9XYZ", 1)] + [InlineData("IK2XYZ", 2)] + [InlineData("JA1XYZ", 4)] + public void YoDxHfPaysEightForRomania(string call, int expected) => + Assert.Equal(expected, LogFor(new YoDxHf()).Judge(TestLog.Contact(call, section: "AR")).Points); + + [Theory] + [InlineData("IK2XYZ", 5)] + [InlineData("JA1XYZ", 10)] + public void RussianDxRttyScoresByContinent(string call, int expected) => + Assert.Equal( + expected, + LogFor(new RussianDxRtty()).Judge(TestLog.Contact(call, mode: Modes.Rtty, section: "14")).Points); + + [Theory] + [InlineData("IK2XYZ/P", 5)] + [InlineData("IK2XYZ", 2)] + [InlineData("JA1XYZ", 3)] + public void FieldDayPaysMostForAPortableStation(string call, int expected) => + Assert.Equal( + expected, + LogFor(new FieldDayRegionOne()).Judge(TestLog.Contact(call, number: 22)).Points); + + /// Every contact is one point, and the continent counts once in the whole + /// contest rather than once per band. + [Fact] + public void BartgCountsAContinentOnce() + { + ContestLog log = LogFor(new BartgSprint()); + Assert.Equal(1, log.Judge(TestLog.Contact("IK2XYZ", mode: Modes.Rtty)).Points); + log.Add(TestLog.Contact("IK2XYZ", mode: Modes.Rtty)); + Assert.DoesNotContain( + log.Judge(TestLog.Contact("F5XYZ", 7_040, mode: Modes.Rtty)).NewMultipliers, + m => m.Index == 3); + } +} diff --git a/tests/Nonemm.Contests.Tests/TestLog.cs b/tests/Nonemm.Contests.Tests/TestLog.cs index cb9076e..e9fd9fb 100644 --- a/tests/Nonemm.Contests.Tests/TestLog.cs +++ b/tests/Nonemm.Contests.Tests/TestLog.cs @@ -26,6 +26,16 @@ public static class TestLog OM; Czech Republic: 15: 28: EU: 49.80: -15.47: -1.0: OK: OK,OL; + France: 14: 27: EU: 46.00: 2.00: -1.0: F: + F; + Ukraine: 16: 29: EU: 50.00: -30.00: -2.0: UR: + UR,US,UT,UY,UZ,EM,EO; + European Russia: 16: 29: EU: 55.75: -37.62: -3.0: UA: + UA,RA,RN,RV,RW,RX,RZ,R3; + Romania: 20: 28: EU: 45.50: -25.00: -2.0: YO: + YO,YP,YQ,YR; + Australia: 30: 59: OC: -23.70: -133.87: -10.0: VK: + VK,AX; """; public static readonly CountryFile CountryFile = CountryFile.Parse(Countries); diff --git a/tests/Nonemm.Contests.Tests/UkrainianDxTests.cs b/tests/Nonemm.Contests.Tests/UkrainianDxTests.cs new file mode 100644 index 0000000..974b63d --- /dev/null +++ b/tests/Nonemm.Contests.Tests/UkrainianDxTests.cs @@ -0,0 +1,25 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +public class UkrainianDxTests +{ + private static ContestLog LogFor() => new(new UkrainianDx(), TestLog.Germany, TestLog.CountryFile); + + [Theory] + [InlineData("UR5XYZ", 10)] + [InlineData("DL9XYZ", 1)] + [InlineData("IK2XYZ", 2)] + [InlineData("JA1XYZ", 3)] + public void UkraineIsWorthTen(string call, int expected) => + Assert.Equal(expected, LogFor().Judge(TestLog.Contact(call, section: "DO")).Points); + + [Fact] + public void OnlyUkraineBringsAnOblast() => + Assert.Equal( + [1], + LogFor().Judge(TestLog.Contact("IK2XYZ", section: "DO")).NewMultipliers + .Select(m => m.Index) + .ToList()); +}