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