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

@@ -9,15 +9,21 @@ public sealed class UserDefinedContest : Contest
{
private readonly UdcFile file;
private readonly UdcPoints points;
private readonly UdcPoints bonusPoints;
private readonly UdcPointsMultiplier pointsMultiplier;
private readonly IReadOnlyList<UdcMultiplier> multipliers;
private readonly IReadOnlyList<ExchangeField> exchangeFields;
private readonly IReadOnlyList<string> workableStations;
public UserDefinedContest(UdcFile file)
{
this.file = file;
points = new UdcPoints(file.Text("PointsPerContact", "1"));
bonusPoints = new UdcPoints(file.Text("BonusPoints"));
pointsMultiplier = new UdcPointsMultiplier(file);
multipliers = ReadMultipliers(file);
exchangeFields = ReadExchangeFields(file);
workableStations = file.List("IsWorkable");
}
public static UserDefinedContest Load(string path) =>
@@ -33,6 +39,7 @@ public sealed class UserDefinedContest : Contest
public IReadOnlyList<string> MultiplierNames =>
new[] { "Multiplier1Name", "Multiplier2Name", "Multiplier3Name" }
.Take(multipliers.Count)
.Select(key => file.Text(key))
.Where(name => name.Length > 0)
.ToList();
@@ -45,9 +52,27 @@ public sealed class UserDefinedContest : Contest
_ => DupeScope.PerBand,
};
/// `DupeSqlString` names one more field that makes a contact distinct: a
/// station worked again with a different section, exchange, mode or grid
/// square is not a dupe.
public string ExtraDupeKey(Qso qso) => file.Number("DupeSqlString", 0) switch
{
1 => qso.Section.Trim().ToUpperInvariant(),
2 => qso.Exchange1.Trim().ToUpperInvariant(),
3 => qso.Mode.Name,
4 => qso.GridSquare.Trim().ToUpperInvariant(),
_ => "",
};
public bool HasSerialNumbers =>
file.Text("DefaultContestExchange").StartsWith("001", StringComparison.Ordinal);
public bool ShowsWarcBands => file.Flag("ShowWarcBands", false);
public bool UsesItuZones =>
file.Text("ZoneType", "CQ").StartsWith("IARU", StringComparison.OrdinalIgnoreCase)
|| file.Text("ZoneType", "CQ").StartsWith("ITU", StringComparison.OrdinalIgnoreCase);
public IReadOnlyList<ModeCategory> Modes => file.Text("Mode", "CW").ToUpperInvariant() switch
{
"CW" => [ModeCategory.Cw],
@@ -58,13 +83,37 @@ public sealed class UserDefinedContest : Contest
public string SentExchangeFor(StationInfo me) => file.Text("DefaultContestExchange", "599");
public int PointsFor(QsoContext qso) => points.For(qso);
public int PointsFor(QsoContext qso)
{
if (!IsWorkable(qso))
{
return 0;
}
string sent = SentExchange(qso);
double scored = points.For(qso, sent) * pointsMultiplier.For(qso);
return (int)Math.Round(scored) + bonusPoints.For(qso, sent);
}
/// What this station sends: the exchange the operator set up for the
/// entry, and the contest's own default until they have.
private string SentExchange(QsoContext qso) =>
qso.Entry.SentExchange.Length > 0 ? qso.Entry.SentExchange : SentExchangeFor(qso.Me);
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso) =>
multipliers.Select(m => m.For(qso)).OfType<Multiplier>().ToList();
IsWorkable(qso)
? multipliers.Select(m => m.For(qso)).OfType<Multiplier>().ToList()
: [];
public int TotalScore(ScoreTally tally) =>
multipliers.Count == 0 ? tally.Points : tally.Points * tally.TotalMultipliers;
/// A contest whose multipliers are all weighted zero scores its points,
/// and one that counts multipliers scores points times at least one, so a
/// log with no multiplier worked yet is not worth nothing.
public int TotalScore(ScoreTally tally, ContestEntry entry)
{
int total = multipliers.Any(m => m.Weight != 0)
? tally.Points * Math.Max(1, tally.TotalMultipliers)
: tally.Points;
return (int)Math.Round(total * pointsMultiplier.ForEntry(entry));
}
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
new(
@@ -79,6 +128,34 @@ public sealed class UserDefinedContest : Contest
new CabrilloField(ReceivedExchangePart(qso), 6),
]);
/// `IsWorkable` names the stations the contest counts: a continent, my own
/// country, everything but my own country, or a list of country prefixes. A
/// station outside it scores nothing and brings no multiplier.
private bool IsWorkable(QsoContext qso)
{
if (workableStations.Count == 0)
{
return true;
}
return workableStations.Any(rule => rule.ToUpperInvariant() switch
{
"ANY" => true,
"MYCONTINENTONLY" => qso.IsSameContinent,
"MYCOUNTRYONLY" => qso.IsSameCountry,
"EXCEPTMYCOUNTRY" => !qso.IsSameCountry,
"NAONLY" => Continent(qso, "NA"),
"SAONLY" => Continent(qso, "SA"),
"EUONLY" => Continent(qso, "EU"),
"ASIAONLY" => Continent(qso, "AS"),
"AFONLY" => Continent(qso, "AF"),
"OCONLY" => Continent(qso, "OC"),
_ => rule.Equals(qso.CountryPrefix, StringComparison.OrdinalIgnoreCase),
});
}
private static bool Continent(QsoContext qso, string continent) =>
continent.Equals(qso.Continent, StringComparison.OrdinalIgnoreCase);
private string SentExchangePart(Qso qso) =>
HasSerialNumbers ? $"{qso.SentNumber:000}" : file.Text("DefaultContestExchange");
@@ -88,29 +165,21 @@ public sealed class UserDefinedContest : Contest
: qso.ReceivedNumber > 0 ? $"{qso.ReceivedNumber:000}"
: qso.MiscText;
/// `NumMults` caps how many of the three the contest counts. A file that
/// leaves it out counts every `MultSqlString` it defines.
private static IReadOnlyList<UdcMultiplier> ReadMultipliers(UdcFile file)
{
UdcMultiplierScope shared = (UdcMultiplierScope)file.Number("IsMultPer", 0);
List<UdcMultiplier> found = [];
for (int index = 1; index <= 3; index++)
{
string source = file.Text(index == 1 ? "MultSqlString" : $"MultSqlString{index}");
if (source.Length == 0)
UdcMultiplier multiplier = new(index, file, shared);
if (multiplier.IsDefined)
{
continue;
found.Add(multiplier);
}
UdcMultiplierScope scope = (UdcMultiplierScope)file.Number($"IsMult{index}Per", 0);
if (scope == UdcMultiplierScope.None)
{
scope = shared == UdcMultiplierScope.None ? UdcMultiplierScope.PerBand : shared;
}
found.Add(new UdcMultiplier(
index,
source,
scope,
file.List(index == 1 ? "CountMultOnlyFor" : $"CountMultOnlyFor{index}")));
}
return found;
return found.Take(file.Number("NumMults", found.Count)).ToList();
}
/// `EntryWindowInfo` names the boxes; `FrameText` labels them. Only the