Read the rest of what a .udc file says, and score what N1MM scores

Two pieces of work on the scoring engine.

The .udc reader now takes N1MM's published scoring vocabulary: the full
PointsPerContact condition set, the PointsMultBy family, PowerMult, BonusPoints,
the multiplier sources and the settings that say which stations bring a
multiplier in. MultMult is a weight rather than a switch, as in N1MM's
ComputeScore. The entry categories and the sent exchange reach the rules in a
new ContestEntry, so a contest can score by the power category it is entered in
or by what this station sends.

Then every contest in a real N1MM log was scored again from these rules and
compared with the points and multiplier flags N1MM wrote. That found five bugs:
ARRL DX and IARU read the exchange from the wrong column, a stored " " was read
as a value rather than as empty, CQ WW RTTY's own Canadian area names were not
counted, a maritime mobile scored nothing, and a contact in a mode the contest
does not run scored as if it were in the contest.

docs/n1mm-interop.md has the check and what still differs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-31 08:33:40 +00:00
parent 9424c488ea
commit 04568d0ca3
28 changed files with 1088 additions and 142 deletions

View File

@@ -12,7 +12,7 @@ public class ArrlDxTests
public void DxWorkingWvieScoresThree()
{
ContestLog log = LogFor(TestLog.Germany);
Assert.Equal(3, log.Judge(TestLog.Contact("K1ABC", exchange: "CT")).Points);
Assert.Equal(3, log.Judge(TestLog.Contact("K1ABC", section: "CT")).Points);
}
[Fact]
@@ -33,8 +33,8 @@ public class ArrlDxTests
public void DxCountsStatesAndProvinces()
{
ContestLog log = LogFor(TestLog.Germany);
Assert.Equal("CT", log.Judge(TestLog.Contact("K1ABC", exchange: "CT")).NewMultipliers.Single().Value);
Assert.Empty(log.Judge(TestLog.Contact("K1ABC", exchange: "XX")).NewMultipliers);
Assert.Equal("CT", log.Judge(TestLog.Contact("K1ABC", section: "CT")).NewMultipliers.Single().Value);
Assert.Empty(log.Judge(TestLog.Contact("K1ABC", section: "XX")).NewMultipliers);
}
[Fact]
@@ -48,8 +48,28 @@ public class ArrlDxTests
public void MultipliersCountOncePerBand()
{
ContestLog log = LogFor(TestLog.Germany);
log.Add(TestLog.Contact("K1ABC", exchange: "CT"));
Assert.Empty(log.Judge(TestLog.Contact("K2ABC", exchange: "CT")).NewMultipliers);
Assert.Single(log.Judge(TestLog.Contact("K2ABC", 7_025, exchange: "CT")).NewMultipliers);
log.Add(TestLog.Contact("K1ABC", section: "CT"));
Assert.Empty(log.Judge(TestLog.Contact("K2ABC", section: "CT")).NewMultipliers);
Assert.Single(log.Judge(TestLog.Contact("K2ABC", 7_025, section: "CT")).NewMultipliers);
}
/// A Canadian station sends the postal code or the older abbreviation, and
/// Newfoundland and Labrador is one province either way. N1MM counts `NF`
/// and `LB` as two multipliers, which is two for one province.
[Fact]
public void NewfoundlandAndLabradorCountOnce()
{
ContestLog log = new(new ArrlDx(ModeCategory.Phone), TestLog.Germany, TestLog.CountryFile);
log.Add(TestLog.Contact("VO1XYZ", section: "NF"));
Assert.Empty(log.Judge(TestLog.Contact("VO2XYZ", section: "LB")).NewMultipliers);
}
[Fact]
public void TheOlderQuebecAbbreviationIsQuebec()
{
ContestLog log = new(new ArrlDx(ModeCategory.Phone), TestLog.Germany, TestLog.CountryFile);
Assert.Equal(
"QC",
log.Judge(TestLog.Contact("VA2XYZ", section: "QB")).NewMultipliers.Single().Value);
}
}

View File

@@ -90,4 +90,24 @@ public class CqWorldWideRttyTests
Assert.Equal(ExchangeSlot.Section, fields[2].Slot);
Assert.False(fields[2].IsRequired);
}
/// The Canadian areas this contest counts are its own list, which splits
/// Newfoundland from Labrador.
[Theory]
[InlineData("NF")]
[InlineData("LB")]
public void TheContestsOwnCanadianAreasCount(string area) =>
Assert.Contains(
LogFor(TestLog.Germany).Judge(Contact("VO1XYZ", 5, area)).NewMultipliers,
m => m.Index == 3 && m.Value == area);
/// A maritime mobile station belongs to no country. CQ WW counts it for the
/// zone alone, and the contact still scores by continent.
[Fact]
public void MaritimeMobileScoresButBringsNoCountry()
{
Verdict verdict = LogFor(TestLog.Germany).Judge(Contact("IK2XYZ/MM", 15));
Assert.Equal(2, verdict.Points);
Assert.DoesNotContain(verdict.NewMultipliers, m => m.Index == 2);
}
}

View File

@@ -66,4 +66,16 @@ public class CqWpxTests
ContestLog log = LogFor(TestLog.Germany);
Assert.Empty(log.Judge(TestLog.Contact("DL1ABC/MM")).NewMultipliers);
}
/// A tuning carrier logged by accident, or any contact in a mode the
/// contest does not run, scores nothing. The prefix still counts, which is
/// what N1MM logs.
[Fact]
public void AContactInAnotherModeScoresNothingAndKeepsItsMultiplier()
{
ContestLog log = new(new CqWpx(ModeCategory.Phone), TestLog.Germany, TestLog.CountryFile);
Verdict verdict = log.Judge(TestLog.Contact("IK2XYZ", mode: Modes.Rtty, number: 5));
Assert.Equal(0, verdict.Points);
Assert.Single(verdict.NewMultipliers);
}
}

View File

@@ -0,0 +1,264 @@
using Nonemm.Contests.Udc;
using Nonemm.Core;
namespace Nonemm.Contests.Tests;
/// The scoring settings a `.udc` file can carry: the points conditions, the
/// points multipliers, the bonus, and where a multiplier comes from.
public class UdcScoringTests
{
private static UserDefinedContest Contest(params string[] settings) =>
new(UdcFile.Parse("[Contest]\nName=SCORETEST\n" + string.Join("\n", settings)));
private static ContestLog LogFor(
UserDefinedContest contest,
StationInfo? me = null,
ContestEntry? entry = null) =>
new(contest, me ?? TestLog.Germany, TestLog.CountryFile, entry);
private static int Points(UserDefinedContest contest, Qso qso, StationInfo? me = null) =>
LogFor(contest, me).Judge(qso).Points;
[Theory]
[InlineData("CT", 5)]
[InlineData("MA", 1)]
public void SectIsMatchesTheReceivedSection(string section, int expected) =>
Assert.Equal(
expected,
Points(
Contest("PointsPerContact=SectIs_CT, 5, OtherContinent, 1"),
TestLog.Contact("K1ABC", section: section)));
[Fact]
public void ExchIsNumSeparatesANumberFromAWord() =>
Assert.Equal(
3,
Points(
Contest("PointsPerContact=ExchIsNum, 3, ExchIsNotNum, 7"),
TestLog.Contact("K1ABC", exchange: "129")));
[Fact]
public void ExchIsEmptyScoresTheContactWithNoExchange() =>
Assert.Equal(
2,
Points(Contest("PointsPerContact=ExchIs_Empty, 2, ExchIsNum, 9"), TestLog.Contact("K1ABC")));
[Fact]
public void AStarScoresTheNumberInTheExchangeBox() =>
Assert.Equal(
42,
Points(Contest("PointsPerContact=Exchange, *"), TestLog.Contact("K1ABC", exchange: "42")));
[Fact]
public void MyCqZoneScoresAStationInMyOwnZone() =>
Assert.Equal(
1,
Points(Contest("PointsPerContact=MyCQZone, 1, OtherContinent, 4"), TestLog.Contact("DL9XYZ")));
/// `SAMECONTINENT+160` is the same continent on one band only.
[Fact]
public void AContinentConditionCanCarryABand()
{
UserDefinedContest contest = Contest("PointsPerContact=SameContinent+160, 6, SameContinent, 2");
Assert.Equal(6, Points(contest, TestLog.Contact("IK2XYZ", 1_830)));
Assert.Equal(2, Points(contest, TestLog.Contact("IK2XYZ")));
}
[Fact]
public void PointsMultByBandScalesWhatTheContactScores()
{
UserDefinedContest contest = Contest("PointsPerContact=3", "PointsMultByBand=160M, 3, 20M, 1");
Assert.Equal(9, Points(contest, TestLog.Contact("IK2XYZ", 1_830)));
Assert.Equal(3, Points(contest, TestLog.Contact("IK2XYZ")));
}
[Fact]
public void PointsMultAtTimeGmtScalesInsideItsWindow()
{
UserDefinedContest contest = Contest("PointsPerContact=2", "PointsMultAtTimeGMT=2300, 0300, 2");
DateTime midnight = new(2026, 5, 30, 0, 30, 0, DateTimeKind.Utc);
Assert.Equal(4, Points(contest, TestLog.Contact("IK2XYZ", at: midnight)));
Assert.Equal(2, Points(contest, TestLog.Contact("IK2XYZ")));
}
[Fact]
public void BonusPointsAreAddedToWhatTheContactScores() =>
Assert.Equal(
26,
Points(
Contest("PointsPerContact=1", "BonusPoints=IK2XYZ, 25"),
TestLog.Contact("IK2XYZ")));
/// A contest can pay the bonus for a new multiplier, so the multiplier has
/// to be worked out before the points are.
[Fact]
public void BonusPointsCanBePaidForANewMultiplier()
{
UserDefinedContest contest = Contest(
"PointsPerContact=1",
"MultSqlString=CountryPrefix",
"IsMultPer=1",
"BonusPoints=IsMult1, 10");
ContestLog log = LogFor(contest);
Assert.Equal(11, log.Judge(TestLog.Contact("IK2XYZ")).Points);
log.Add(TestLog.Contact("IK2XYZ"));
Assert.Equal(1, log.Judge(TestLog.Contact("IK3XYZ")).Points);
}
[Fact]
public void ADistanceTableScoresByTheRangeTheContactFallsIn()
{
UserDefinedContest contest = Contest("PointsPerContact=0/100/7");
StationInfo me = TestLog.Germany with { GridSquare = "JO60" };
Qso near = TestLog.Contact("DL9XYZ") with { GridSquare = "JO60" };
Qso far = TestLog.Contact("JA1XYZ") with { GridSquare = "PM95" };
Assert.Equal(7, Points(contest, near, me));
Assert.True(Points(contest, far, me) > 1_000);
}
[Theory]
[InlineData("MultSqlString=2LPREFIX", "IK")]
[InlineData("MultSqlString=LastLetter", "Z")]
[InlineData("MultSqlString=EU_Country", "I")]
public void TheMultiplierValueComesFromTheSource(string setting, string expected)
{
UserDefinedContest contest = Contest(setting, "IsMultPer=4");
Verdict verdict = LogFor(contest).Judge(TestLog.Contact("IK2XYZ"));
Assert.Equal(expected, Assert.Single(verdict.NewMultipliers).Value);
}
[Fact]
public void ACountryOnAnotherContinentBringsNoContinentMultiplier() =>
Assert.Empty(
LogFor(Contest("MultSqlString=EU_Country", "IsMultPer=4"))
.Judge(TestLog.Contact("JA1XYZ"))
.NewMultipliers);
[Fact]
public void CountMultOnlyForTakesAContinentAsWellAsACountry()
{
UserDefinedContest contest = Contest(
"MultSqlString=CountryPrefix",
"IsMultPer=4",
"CountMultOnlyFor=EU");
Assert.Single(LogFor(contest).Judge(TestLog.Contact("IK2XYZ")).NewMultipliers);
Assert.Empty(LogFor(contest).Judge(TestLog.Contact("JA1XYZ")).NewMultipliers);
}
[Fact]
public void DoNotCountMultOnlyForLeavesThoseStationsOut() =>
Assert.Empty(
LogFor(Contest("MultSqlString=CountryPrefix", "IsMultPer=4", "DoNotCountMultOnlyFor=JA"))
.Judge(TestLog.Contact("JA1XYZ"))
.NewMultipliers);
[Fact]
public void DoNotCountMeAsMultLeavesOutMyOwnCountry() =>
Assert.Empty(
LogFor(Contest("MultSqlString=CountryPrefix", "IsMultPer=4", "DoNotCountMeAsMult=True"))
.Judge(TestLog.Contact("DL9XYZ"))
.NewMultipliers);
[Fact]
public void MultMultSaysWhatAMultiplierIsWorth()
{
ContestLog log = LogFor(Contest(
"PointsPerContact=1",
"MultSqlString=CountryPrefix",
"IsMultPer=4",
"MultMult=2"));
log.Add(TestLog.Contact("JA1XYZ"));
log.Add(TestLog.Contact("IK2XYZ"));
Assert.Equal(8, log.TotalScore);
}
/// A multiplier weighted zero is still worked, and adds nothing.
[Fact]
public void MultMultZeroLeavesTheScoreAtThePoints()
{
ContestLog log = LogFor(Contest(
"PointsPerContact=3",
"MultSqlString=CountryPrefix",
"IsMultPer=4",
"MultMult=0"));
log.Add(TestLog.Contact("JA1XYZ"));
Assert.Equal(3, log.TotalScore);
}
[Fact]
public void PowerMultScalesTheWholeScoreByTheEntryCategory()
{
UserDefinedContest contest = Contest("PointsPerContact=2", "PowerMult=QRP, 3, LP, 2, HP, 1");
ContestLog qrp = LogFor(contest, entry: new ContestEntry { PowerCategory = "QRP" });
ContestLog low = LogFor(contest, entry: new ContestEntry { PowerCategory = "LOW" });
qrp.Add(TestLog.Contact("JA1XYZ"));
low.Add(TestLog.Contact("JA1XYZ"));
Assert.Equal(6, qrp.TotalScore);
Assert.Equal(4, low.TotalScore);
}
/// `PointsMultByCategory` is about the station being worked, not the entry:
/// N1MM scales only a callsign signing QRP.
[Fact]
public void PointsMultByCategoryScalesAStationSigningQrp()
{
UserDefinedContest contest = Contest("PointsPerContact=2", "PointsMultByCategory=/QRP, 3");
Assert.Equal(6, Points(contest, TestLog.Contact("JA1XYZ/QRP")));
Assert.Equal(2, Points(contest, TestLog.Contact("JA1XYZ")));
}
[Fact]
public void MyExchangeComparesWhatThisStationSends()
{
UserDefinedContest contest = Contest("PointsPerContact=MyExchange, 1, OtherContinent, 5");
ContestEntry entry = new() { SentExchange = "14" };
Assert.Equal(
1,
LogFor(contest, entry: entry).Judge(TestLog.Contact("JA1XYZ", section: "14")).Points);
Assert.Equal(
5,
LogFor(contest, entry: entry).Judge(TestLog.Contact("JA1XYZ", section: "15")).Points);
}
[Fact]
public void NumMultsCapsHowManyAreCounted()
{
UserDefinedContest contest = Contest(
"MultSqlString=CountryPrefix",
"MultSqlString2=Continent",
"IsMultPer=4",
"NumMults=1");
Assert.Single(LogFor(contest).Judge(TestLog.Contact("JA1XYZ")).NewMultipliers);
}
[Fact]
public void DupeSqlStringMakesASecondSectionADifferentContact()
{
UserDefinedContest contest = Contest("DupeType=1", "DupeSqlString=1");
ContestLog log = LogFor(contest);
log.Add(TestLog.Contact("K1ABC", section: "CT"));
Assert.False(log.Judge(TestLog.Contact("K1ABC", section: "MA")).IsDupe);
Assert.True(log.Judge(TestLog.Contact("K1ABC", section: "CT")).IsDupe);
}
[Fact]
public void IsWorkableLeavesOutTheStationsTheContestDoesNotCount()
{
UserDefinedContest contest = Contest(
"PointsPerContact=3",
"MultSqlString=CountryPrefix",
"IsMultPer=4",
"IsWorkable=EUonly");
Verdict outside = LogFor(contest).Judge(TestLog.Contact("JA1XYZ"));
Assert.Equal(0, outside.Points);
Assert.Empty(outside.NewMultipliers);
Assert.Equal(3, Points(contest, TestLog.Contact("IK2XYZ")));
}
[Fact]
public void ZoneTypeIaruPutsTheContestOnItuZones()
{
Assert.True(Contest("ZoneType=IARU").UsesItuZones);
Assert.False(Contest("ZoneType=CQ").UsesItuZones);
}
}