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
co-authored by Claude Opus 5
parent 9424c488ea
commit 04568d0ca3
28 changed files with 1088 additions and 142 deletions
+38 -11
View File
@@ -15,20 +15,29 @@ public sealed class ContestLog
private readonly HashSet<string> claimedMultipliers = new(StringComparer.Ordinal);
private ScoreTally tally = new();
public ContestLog(Contest contest, StationInfo me, CountryFile? countries)
public ContestLog(
Contest contest,
StationInfo me,
CountryFile? countries,
ContestEntry? entry = null)
{
this.contest = contest;
this.me = me;
this.countries = countries;
Entry = entry ?? new ContestEntry();
}
/// The categories and sent exchange of the running entry. The operator can
/// change them while the contest is open, and the score follows.
public ContestEntry Entry { get; set; }
public Contest Contest => contest;
public IReadOnlyList<Qso> Qsos => qsos;
public ScoreTally Tally => tally;
public int TotalScore => contest.TotalScore(tally);
public int TotalScore => contest.TotalScore(tally, Entry);
/// What logging this contact right now would do, without logging it.
public Verdict Judge(Qso candidate)
@@ -50,7 +59,10 @@ public sealed class ContestLog
newOnes.Add(multiplier);
}
}
return new Verdict(false, contest.PointsFor(context), newOnes);
// a contest can pay a bonus for a new multiplier, so the flags have to
// be set before the points are worked out
QsoContext scored = ContextFor(ApplyMultiplierFlags(candidate, newOnes));
return new Verdict(false, IsContestMode(candidate) ? contest.PointsFor(scored) : 0, newOnes);
}
/// Adds the contact and returns it with points and multiplier flags filled in.
@@ -108,15 +120,28 @@ public sealed class ContestLog
.OrderByDescending(q => q.TimestampUtc)
.ToList();
private QsoContext ContextFor(Qso qso) =>
new(qso, countries?.Find(qso.Call), me);
/// A contact in a mode the contest does not run — a tuning carrier logged
/// by accident, or a CW contact in an SSB contest — scores nothing. It
/// still brings its multiplier, which is what N1MM logs. A contest with no
/// mode of its own takes every mode.
private bool IsContestMode(Qso qso) =>
contest.Modes.Count == 0 || contest.Modes.Contains(qso.Mode.Category);
private Qso ApplyVerdict(Qso qso, Verdict verdict) => qso with
/// A maritime or aeronautical mobile belongs to no country, and the
/// contest still has to know which continent it is working, so the call it
/// is signing from is looked up when the call as sent places nowhere. What
/// that country counts for is the contest's decision.
private QsoContext ContextFor(Qso qso) =>
new(qso, countries?.Find(qso.Call) ?? countries?.Find(qso.Call.Station), me, Entry);
private static Qso ApplyVerdict(Qso qso, Verdict verdict) =>
ApplyMultiplierFlags(qso, verdict.NewMultipliers) with { Points = verdict.Points };
private static Qso ApplyMultiplierFlags(Qso qso, IReadOnlyList<Multiplier> claimed) => qso with
{
Points = verdict.Points,
IsMultiplier1 = verdict.NewMultipliers.Any(m => m.Index == 1),
IsMultiplier2 = verdict.NewMultipliers.Any(m => m.Index == 2),
IsMultiplier3 = verdict.NewMultipliers.Any(m => m.Index == 3),
IsMultiplier1 = claimed.Any(m => m.Index == 1),
IsMultiplier2 = claimed.Any(m => m.Index == 2),
IsMultiplier3 = claimed.Any(m => m.Index == 3),
};
private void Index(Qso qso, Verdict verdict)
@@ -155,7 +180,8 @@ public sealed class ContestLog
private string DupeKey(Qso qso)
{
string call = qso.Call.Text.ToUpperInvariant();
return contest.DupeScope switch
string extra = contest.ExtraDupeKey(qso);
string key = contest.DupeScope switch
{
DupeScope.Once => call,
DupeScope.PerBand => $"{call}|{qso.Band?.Name}",
@@ -163,6 +189,7 @@ public sealed class ContestLog
DupeScope.PerBandAndMode => $"{call}|{qso.Band?.Name}|{qso.Mode.Category}",
_ => call,
};
return extra.Length == 0 ? key : $"{key}|{extra}";
}
private static string MultiplierKey(Multiplier multiplier) =>