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:
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user