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

@@ -3,50 +3,102 @@ using Nonemm.Core;
namespace Nonemm.Contests.Udc;
/// One of a user-defined contest's up-to-three multipliers: where its value
/// comes from and how often it may be counted.
/// comes from, which stations may bring it in, and how often it may be counted.
///
/// Left out: `CallHist`, which counts a value from the call history file, and
/// needs that file loaded before a multiplier can be worked out.
public sealed class UdcMultiplier
{
private readonly int index;
private readonly string source;
private readonly UdcMultiplierScope scope;
private readonly IReadOnlyList<string> onlyForCountries;
private readonly int weight;
private readonly bool skipsMyOwn;
private readonly IReadOnlyList<string> onlyFor;
private readonly IReadOnlyList<string> notFor;
private readonly IReadOnlyList<string> onlyForSections;
public UdcMultiplier(
int index,
string source,
UdcMultiplierScope scope,
IReadOnlyList<string> onlyForCountries)
public UdcMultiplier(int index, UdcFile file, UdcMultiplierScope shared)
{
this.index = index;
this.source = source;
this.scope = scope;
this.onlyForCountries = onlyForCountries;
string suffix = index == 1 ? "" : index.ToString();
source = file.Text($"MultSqlString{suffix}").Trim().ToUpperInvariant();
UdcMultiplierScope own = (UdcMultiplierScope)file.Number($"IsMult{index}Per", 0);
scope = own != UdcMultiplierScope.None ? own
: shared != UdcMultiplierScope.None ? shared
: UdcMultiplierScope.PerBand;
weight = file.Number($"MultMult{suffix}", 1);
skipsMyOwn = file.Flag($"DoNotCountMeAsMult{suffix}", false);
onlyFor = file.List($"CountMultOnlyFor{suffix}");
notFor = file.List($"DoNotCountMultOnlyFor{suffix}");
onlyForSections = file.List($"CountMultOnlyForSec{suffix}");
}
public bool IsDefined => source.Length > 0;
/// What one of these is worth in the score. `MultMult` is a weight, not a
/// switch: a contest can count a multiplier twice, and one weighted zero
/// still shows as worked and adds nothing.
public int Weight => weight;
public Multiplier? For(QsoContext qso)
{
if (scope == UdcMultiplierScope.None)
{
return null;
}
if (onlyForCountries.Count > 0 &&
!onlyForCountries.Contains(qso.CountryPrefix, StringComparer.OrdinalIgnoreCase))
{
return null;
}
string? value = ValueFor(qso);
return string.IsNullOrEmpty(value) ? null : new Multiplier(index, value, ScopeKey(qso));
if (scope == UdcMultiplierScope.None || string.IsNullOrEmpty(value))
{
return null;
}
return Counts(qso, value) ? new Multiplier(index, value, ScopeKey(qso), weight) : null;
}
private string? ValueFor(QsoContext qso) => source.ToUpperInvariant() switch
/// `CountMultOnlyFor` and `DoNotCountMultOnlyFor` hold country prefixes or
/// continent abbreviations, and for a prefix multiplier the prefixes
/// themselves, so a station is matched against all three.
private bool Counts(QsoContext qso, string value)
{
if (skipsMyOwn && IsMyOwn(qso, value))
{
return false;
}
if (onlyFor.Count > 0 && !onlyFor.Any(p => Names(qso, value, p)))
{
return false;
}
if (notFor.Any(p => Names(qso, value, p)))
{
return false;
}
return onlyForSections.Count == 0
|| onlyForSections.Contains(qso.Qso.Section.Trim(), StringComparer.OrdinalIgnoreCase);
}
private bool IsMyOwn(QsoContext qso, string value) =>
IsPrefixSource
? qso.Me.Callsign.StartsWith(value, StringComparison.OrdinalIgnoreCase)
: qso.IsSameCountry;
private bool Names(QsoContext qso, string value, string wanted) =>
wanted.Equals(qso.CountryPrefix, StringComparison.OrdinalIgnoreCase)
|| wanted.Equals(qso.Continent, StringComparison.OrdinalIgnoreCase)
|| (IsPrefixSource && wanted.Equals(value, StringComparison.OrdinalIgnoreCase));
private bool IsPrefixSource => source is "WPXPREFIX" or "2LPREFIX";
private string? ValueFor(QsoContext qso) => source switch
{
"COUNTRYPREFIX" => qso.CountryPrefix,
"EU_COUNTRY" or "AS_COUNTRY" or "NA_COUNTRY" or "SA_COUNTRY" or "AF_COUNTRY" or "OC_COUNTRY" =>
CountryOnContinent(qso, source[..2]),
"WPXPREFIX" => qso.Qso.Call.WpxPrefix(),
"2LPREFIX" => Truncate(qso.Qso.Call.Text, 2),
"LASTLETTER" => LastLetter(qso.Qso.Call.Text),
"SECTION" or "SECT" => Upper(qso.Qso.Section),
"EXCHANGE" or "EXCH" => Upper(qso.Qso.Exchange1),
"MISC" or "MISCTEXT" => Upper(qso.Qso.MiscText),
"COMMENT" => Upper(qso.Qso.Comment),
"CALLSIGN" => qso.Qso.Call.Text,
"CQZONE" or "ZN" => qso.Qso.Zone > 0 ? qso.Qso.Zone.ToString() : null,
"CQZONE" or "ZN" => ZoneName(qso.Qso.Zone, qso.CqZone),
"IARUZONE" => ZoneName(qso.Qso.Zone, qso.ItuZone),
"GRID" => Truncate(qso.Qso.GridSquare, 4),
"SGRID" => Truncate(qso.Qso.GridSquare, 6),
"FIELD" => Truncate(qso.Qso.GridSquare, 2),
@@ -55,6 +107,16 @@ public sealed class UdcMultiplier
_ => null,
};
private static string? CountryOnContinent(QsoContext qso, string continent) =>
continent.Equals(qso.Continent, StringComparison.OrdinalIgnoreCase) ? qso.CountryPrefix : null;
/// The zone the operator typed, and the country file's zone when the box is
/// empty.
private static string? ZoneName(int received, int fromCountryFile) =>
received > 0 ? received.ToString()
: fromCountryFile > 0 ? fromCountryFile.ToString()
: null;
private string ScopeKey(QsoContext qso) => scope switch
{
UdcMultiplierScope.PerBand => qso.Band?.Name ?? "",
@@ -65,6 +127,12 @@ public sealed class UdcMultiplier
private static string Upper(string text) => text.Trim().ToUpperInvariant();
private static string? LastLetter(string call)
{
string trimmed = call.Trim().ToUpperInvariant();
return trimmed.Length == 0 ? null : trimmed[^1..];
}
private static string? Truncate(string text, int length)
{
string trimmed = text.Trim().ToUpperInvariant();