Score CQ WW RTTY as its own contest

CQWWRTTY resolved to the built-in CQ WW with the mode set to digital, and then
scored it as though it were the CW running: a contact inside your own country
worth nothing, two multiplier types, and CQ-WW-SSB in the Cabrillo header. Every
one of those is wrong for RTTY, and wrong quietly, which is the worst way.

RTTY is a different contest wearing the same name. Every contact scores — 1
inside your own country, 2 on your own continent, 3 off it. The exchange carries
the state or province after the zone, and that is a third multiplier per band.
A station outside the US and Canada sends DX there, so the box is not required
and DX brings no multiplier.

The state box being optional is deliberate: most of the log is DX stations, and
holding up an entry for a box that will hold DX costs more than it is worth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 23:37:54 +00:00
parent 0ef12ff8be
commit 86c01f246f
5 changed files with 189 additions and 20 deletions

View File

@@ -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 |

View File

@@ -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

View File

@@ -10,7 +10,7 @@ public sealed class ContestRegistry
{
private static readonly IReadOnlyList<ContestChoice> 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()),

View File

@@ -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<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
/// 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<ExchangeField> 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<string> MultiplierNames => ["Zones", "Countries"];
public IReadOnlyList<string> MultiplierNames =>
IsRtty ? ["Zones", "Countries", "States"] : ["Zones", "Countries"];
public DupeScope DupeScope => DupeScope.PerBand;
@@ -37,8 +58,10 @@ public sealed class CqWorldWide : Contest
public IReadOnlyList<ModeCategory> 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",
};
}

View File

@@ -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<ExchangeField> fields = Rtty.ExchangeFieldsFor(TestLog.Germany);
Assert.Equal(3, fields.Count);
Assert.Equal(ExchangeSlot.Section, fields[2].Slot);
Assert.False(fields[2].IsRequired);
}
}