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()); +}