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