diff --git a/README.md b/README.md index de9e20e..6f81234 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, CQ WPX, ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, general logging, and user-defined `.udc` contests | +| Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, 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, packet | diff --git a/docs/unfinished.md b/docs/unfinished.md index ebc5931..7144a81 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -45,10 +45,23 @@ data. The file is read without them. the contest rules score it, but there is no digital window: no decoding, no transmitting, no interface to fldigi, MMTTY or similar. -**Contest coverage.** Eight contests are built in, plus whatever `.udc` files +**Contest coverage.** Eight 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 not in the registry fails with the contest name, which is the -right answer but it is still a wall. +from a contest that is in neither place fails with the contest name, which is +the right answer but it is still a wall. + +Most of what is missing does not need code. A contest whose exchange is a +report and one value, scored by band, mode, continent or country, and counted +by country, zone, section or prefix, is a `.udc` file, and the file published +for N1MM is read as it is. Code is for the ones `.udc` has no words for: WAE +needs QTC traffic, IOTA needs island references as multipliers, and CQ WW RTTY +needed a third multiplier and its own points table because it shares a name +with the CW and SSB running of the contest. + +What `.udc` cannot say here yet: an exchange that differs by the other +station's country (the OK/OM district against a serial number from everyone +else), points that depend on a zone or a distance, and multiplier sources +beyond country, prefix, zone, section, exchange, grid and continent. ## Known rough edges diff --git a/src/Nonemm.Contests/ContestRegistry.cs b/src/Nonemm.Contests/ContestRegistry.cs index 839039f..bfffa28 100644 --- a/src/Nonemm.Contests/ContestRegistry.cs +++ b/src/Nonemm.Contests/ContestRegistry.cs @@ -10,7 +10,7 @@ public sealed class ContestRegistry { private static readonly IReadOnlyList BuiltIn = [ - new("CQWW", "CQ World Wide DX", [ModeCategory.Cw, ModeCategory.Phone], m => new CqWorldWide(m)), + new("CQWW", "CQ World Wide DX", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new CqWorldWide(m)), new("CQWPX", "CQ WPX", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new CqWpx(m)), new("ARRLDX", "ARRL International DX", [ModeCategory.Cw, ModeCategory.Phone], m => new ArrlDx(m)), new("IARU", "IARU HF World Championship", [ModeCategory.Cw, ModeCategory.Phone], _ => new IaruHf()), diff --git a/src/Nonemm.Contests/Rules/CqWorldWide.cs b/src/Nonemm.Contests/Rules/CqWorldWide.cs index c217c5d..859a171 100644 --- a/src/Nonemm.Contests/Rules/CqWorldWide.cs +++ b/src/Nonemm.Contests/Rules/CqWorldWide.cs @@ -1,10 +1,15 @@ +using Nonemm.Contests.Multipliers; using Nonemm.Core; namespace Nonemm.Contests.Rules; -/// CQ World Wide DX, CW and SSB. Points by continent, multipliers are zones and -/// countries counted once per band. The country list is DXCC plus WAE, which is -/// what `wl_cty.dat` holds. +/// CQ World Wide DX, CW, SSB and RTTY. Points by continent, multipliers are +/// zones and countries counted once per band. The country list is DXCC plus +/// WAE, which is what `wl_cty.dat` holds. +/// +/// RTTY is a different contest wearing the same name: the exchange carries the +/// state or province as well as the zone, that is a third multiplier, and every +/// contact scores — a contact inside your own country counts 1 rather than 0. public sealed class CqWorldWide : Contest { private readonly ModeCategory mode; @@ -20,15 +25,31 @@ public sealed class CqWorldWide : Contest public string DisplayName => $"CQ World Wide DX {ModeLabel()}"; - public string CabrilloName => mode == ModeCategory.Cw ? "CQ-WW-CW" : "CQ-WW-SSB"; + public string CabrilloName => mode switch + { + ModeCategory.Cw => "CQ-WW-CW", + ModeCategory.Phone => "CQ-WW-SSB", + _ => "CQ-WW-RTTY", + }; - public IReadOnlyList ExchangeFieldsFor(StationInfo me) => - [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("Zone", ExchangeSlot.Zone, ExchangeFieldKind.CqZone), - ]; + /// The state or province box is not required: a station outside the US and + /// Canada sends `DX` there, and holding up the log for it would cost more + /// than it is worth. + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => IsRtty + ? + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Zone", ExchangeSlot.Zone, ExchangeFieldKind.CqZone), + new ExchangeField("S/P", ExchangeSlot.Section, ExchangeFieldKind.UsStateOrCanadianProvince, IsRequired: false), + ] + : + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Zone", ExchangeSlot.Zone, ExchangeFieldKind.CqZone), + ]; - public IReadOnlyList MultiplierNames => ["Zones", "Countries"]; + public IReadOnlyList MultiplierNames => + IsRtty ? ["Zones", "Countries", "States"] : ["Zones", "Countries"]; public DupeScope DupeScope => DupeScope.PerBand; @@ -37,8 +58,10 @@ public sealed class CqWorldWide : Contest public IReadOnlyList Modes => [mode]; /// The report is fixed for the whole contest, so the sent exchange is the - /// zone alone, which is also what N1MM stores. - public string SentExchangeFor(StationInfo me) => me.CqZone.ToString(); + /// zone alone, which is also what N1MM stores. RTTY sends the state or + /// province after it, and `DX` from anywhere else. + public string SentExchangeFor(StationInfo me) => + IsRtty ? $"{me.CqZone} {HomeOf(me)}" : me.CqZone.ToString(); public int PointsFor(QsoContext qso) { @@ -46,6 +69,10 @@ public sealed class CqWorldWide : Contest { return 0; } + if (IsRtty) + { + return qso.IsSameCountry ? 1 : qso.IsSameContinent ? 2 : 3; + } if (qso.IsSameCountry) { return 0; @@ -69,13 +96,31 @@ public sealed class CqWorldWide : Contest { found.Add(new Multiplier(2, qso.CountryPrefix, band)); } + string home = qso.Qso.Section.Trim().ToUpperInvariant(); + if (IsRtty && StatesAndProvinces.IsStateOrProvince(home)) + { + found.Add(new Multiplier(3, home, band)); + } return found; } public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers; - public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => - new( + public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => IsRtty + ? new CabrilloExchange( + [ + new CabrilloField(me.Callsign, 13), + new CabrilloField(qso.SentReport, 3), + new CabrilloField(me.CqZone.ToString(), 3), + new CabrilloField(HomeOf(me), 6), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField(qso.ReceivedReport, 3), + new CabrilloField(qso.Zone.ToString(), 3), + new CabrilloField(Home(qso.Section), 6), + ]) + : new CabrilloExchange( [ new CabrilloField(me.Callsign, 13), new CabrilloField(qso.SentReport, 3), @@ -87,6 +132,24 @@ public sealed class CqWorldWide : Contest new CabrilloField(qso.Zone.ToString(), 6), ]); - private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB"; + private bool IsRtty => mode == ModeCategory.Digital; + + /// What goes in the state column: the state or province, and `DX` for + /// everywhere else, which is what the contest asks a DX station to send. + private static string HomeOf(StationInfo me) => + Home(me.State.Length > 0 ? me.State : me.Province); + + private static string Home(string text) + { + string value = text.Trim().ToUpperInvariant(); + return StatesAndProvinces.IsStateOrProvince(value) ? value : "DX"; + } + + private string ModeLabel() => mode switch + { + ModeCategory.Cw => "CW", + ModeCategory.Phone => "SSB", + _ => "RTTY", + }; } diff --git a/tests/Nonemm.Contests.Tests/CqWorldWideRttyTests.cs b/tests/Nonemm.Contests.Tests/CqWorldWideRttyTests.cs new file mode 100644 index 0000000..cca8e5f --- /dev/null +++ b/tests/Nonemm.Contests.Tests/CqWorldWideRttyTests.cs @@ -0,0 +1,93 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +/// CQ WW RTTY is scored differently from the CW and SSB running of the same +/// contest, so it gets its own tests rather than sharing them. +public class CqWorldWideRttyTests +{ + private static readonly Contest Rtty = new CqWorldWide(ModeCategory.Digital); + + private static ContestLog LogFor(StationInfo me) => new(Rtty, me, TestLog.CountryFile); + + private static Qso Contact(string call, int zone, string stateOrProvince = "") => + TestLog.Contact(call, mode: Modes.Rtty, zone: zone, section: stateOrProvince); + + [Fact] + public void ContactInsideYourOwnCountryScoresOne() + { + ContestLog log = LogFor(TestLog.Germany); + Assert.Equal(1, log.Judge(Contact("DL9XYZ", 14)).Points); + } + + [Fact] + public void SameContinentScoresTwo() + { + ContestLog log = LogFor(TestLog.Germany); + Assert.Equal(2, log.Judge(Contact("IK2XYZ", 15)).Points); + } + + [Fact] + public void DifferentContinentScoresThree() + { + ContestLog log = LogFor(TestLog.Germany); + Assert.Equal(3, log.Judge(Contact("JA1XYZ", 25)).Points); + } + + [Fact] + public void TheStateIsAThirdMultiplier() + { + ContestLog log = LogFor(TestLog.Germany); + Verdict verdict = log.Judge(Contact("K1ABC", 5, "CT")); + + Assert.Equal( + ["5", "K", "CT"], + verdict.NewMultipliers.Select(m => m.Value).ToList()); + } + + [Fact] + public void AStationOutsideTheUnitedStatesAndCanadaBringsNoStateMultiplier() + { + ContestLog log = LogFor(TestLog.Germany); + Verdict verdict = log.Judge(Contact("JA1XYZ", 25, "DX")); + + Assert.Equal(["25", "JA"], verdict.NewMultipliers.Select(m => m.Value).ToList()); + } + + [Fact] + public void TheStateCountsAgainOnAnotherBand() + { + ContestLog log = LogFor(TestLog.Germany); + log.Add(Contact("K1ABC", 5, "CT")); + Verdict verdict = log.Judge( + TestLog.Contact("K2DEF", kilohertz: 7_040, mode: Modes.Rtty, zone: 5, section: "CT")); + + Assert.Contains(verdict.NewMultipliers, m => m.Value == "CT"); + } + + [Fact] + public void TheThreeMultiplierNamesAreShown() => + Assert.Equal(["Zones", "Countries", "States"], Rtty.MultiplierNames); + + [Fact] + public void TheCabrilloNameIsTheRttyOne() => Assert.Equal("CQ-WW-RTTY", Rtty.CabrilloName); + + /// A station outside the US and Canada sends DX in the state column. + [Fact] + public void TheSentExchangeCarriesTheStateOrDx() + { + Assert.Equal("14 DX", Rtty.SentExchangeFor(TestLog.Germany)); + Assert.Equal("5 CT", Rtty.SentExchangeFor(TestLog.UnitedStates)); + } + + [Fact] + public void TheExchangeHasAStateBoxThatIsNotRequired() + { + IReadOnlyList fields = Rtty.ExchangeFieldsFor(TestLog.Germany); + + Assert.Equal(3, fields.Count); + Assert.Equal(ExchangeSlot.Section, fields[2].Slot); + Assert.False(fields[2].IsRequired); + } +}