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
+114
View File
@@ -0,0 +1,114 @@
using Nonemm.Core;
namespace Nonemm.Contests.Udc;
/// One condition from a `.udc` scoring list, tested against a contact.
///
/// N1MM's published keywords, plus three prefixed forms: `SectIs_x`, `ExchIs_x`
/// and `MiscIs_x` compare the seventh character onwards with the received
/// section, exchange or misc box. A condition that is none of the keywords is
/// taken as a country prefix or a whole callsign.
public static class UdcCondition
{
public static bool Matches(QsoContext qso, string condition, string mySentExchange)
{
string test = condition.Trim().ToUpperInvariant();
return test switch
{
"" => false,
"MYCOUNTRY" => qso.IsSameCountry,
"MYEXCHANGE" => Same(qso.Qso.Section, mySentExchange),
"MYGRID" => SameGridField(qso.Qso.GridSquare, qso.Me.GridSquare),
"MYCQZONE" => qso.CqZone > 0 && qso.CqZone == qso.Me.CqZone,
"MYIARUZONE" => qso.ItuZone > 0 && qso.ItuZone == qso.Me.ItuZone,
"SAMECONTINENT" => qso.IsSameContinent,
"OTHERCONTINENT" => qso.Continent.Length > 0 && !qso.IsSameContinent,
"ISEXCH" => qso.Qso.Exchange1.Trim().Length > 0,
"ISMISC" => qso.Qso.MiscText.Trim().Length > 0,
"ISCOMMENT" => qso.Qso.Comment.Trim().Length > 0,
"ISMULT1" => qso.Qso.IsMultiplier1,
"ISMULT2" => qso.Qso.IsMultiplier2,
"ISMULT3" => qso.Qso.IsMultiplier3,
"EXCHISNUM" => IsNumber(qso.Qso.Exchange1),
"EXCHISNOTNUM" => qso.Qso.Exchange1.Trim().Length > 0 && !IsNumber(qso.Qso.Exchange1),
"EXCHIS_EMPTY" => qso.Qso.Exchange1.Trim().Length == 0,
"MISCISNUM" => IsNumber(qso.Qso.MiscText),
_ => MatchesLonger(qso, test),
};
}
private static bool MatchesLonger(QsoContext qso, string test)
{
if (test.Length > 7)
{
string value = test[7..];
switch (test[..6])
{
case "SECTIS":
return Same(qso.Qso.Section, value);
case "EXCHIS":
return Same(qso.Qso.Exchange1, value);
case "MISCIS":
return Same(qso.Qso.MiscText, value);
}
}
int plus = test.IndexOf('+');
if (plus > 0)
{
return Matches(qso, test[..plus], "") && IsBand(qso, test[(plus + 1)..]);
}
return IsBand(qso, test)
|| IsMode(qso, test)
|| IsContinent(qso, test)
|| IsCallSuffix(qso, test)
|| Same(qso.CountryPrefix, test)
|| Same(qso.Qso.Call.Text, test);
}
/// The band names a `.udc` file uses are the band names: `160M`, `70CM`.
/// A points condition may leave the `M` off — `SAMECONTINENT+160`.
private static bool IsBand(QsoContext qso, string test) =>
qso.Band is not null &&
(Same(qso.Band.Name, test) || Same(qso.Band.Name, test + "M"));
private static bool IsMode(QsoContext qso, string test) => test switch
{
"CW" => qso.ModeCategory == ModeCategory.Cw,
"SSB" or "USB" or "LSB" => qso.ModeCategory == ModeCategory.Phone,
"DIGI" => qso.ModeCategory == ModeCategory.Digital,
"RTTY" or "PSK" or "FT8" or "FT4" or "FM" or "AM" => Same(qso.Qso.Mode.Name, test),
_ => false,
};
private static bool IsContinent(QsoContext qso, string test) => test switch
{
"EU" or "NA" or "SA" or "AS" or "AF" or "OC" or "AN" => Same(qso.Continent, test),
"MYCONTINENT" => qso.IsSameContinent,
_ => false,
};
/// `/P`, `/M` and `/QRP` match what the station signs after the last slash.
private static bool IsCallSuffix(QsoContext qso, string test)
{
if (!test.StartsWith('/'))
{
return false;
}
string call = qso.Qso.Call.Text;
int slash = call.LastIndexOf('/');
return slash >= 0 && Same(call[slash..], test);
}
private static bool SameGridField(string theirs, string mine) =>
theirs.Trim().Length >= 4 && mine.Trim().Length >= 4 &&
Same(theirs.Trim()[..4], mine.Trim()[..4]);
private static bool IsNumber(string text)
{
string trimmed = text.Trim();
return trimmed.Length > 0 && trimmed.All(char.IsAsciiDigit);
}
private static bool Same(string left, string right) =>
left.Trim().Equals(right.Trim(), StringComparison.OrdinalIgnoreCase);
}
+90 -22
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();
+75 -49
View File
@@ -1,16 +1,27 @@
using Nonemm.Core;
using System.Globalization;
namespace Nonemm.Contests.Udc;
/// Reads N1MM's `PointsPerContact` setting. A plain number scores every contact
/// the same; a comma list gives a condition and a score in turn, and the first
/// condition in the list that matches wins. N1MM's help describes an order of
/// precedence, but its code walks the list left to right, and a file written
/// for N1MM has to score the same here.
/// Reads N1MM's `PointsPerContact` setting, and the same format in
/// `BonusPoints`. A plain number scores every contact the same; a comma list
/// gives a condition and a score in turn, and the first condition in the list
/// that matches wins. N1MM's help describes an order of precedence, but its
/// code walks the list left to right, and a file written for N1MM has to score
/// the same here.
///
/// Two more forms. `Exchange, *` and `Misc, *` score the contact the number the
/// operator typed in that box. A list of `low/high/points` ranges separated by
/// semicolons scores by the distance to the other station's grid square: a
/// contact inside a range scores the range's points on top of whatever the
/// comma list gave, and one outside every range scores the distance itself in
/// kilometres, which is what N1MM does.
public sealed class UdcPoints
{
private readonly int flat;
private readonly List<(string Condition, int Points)> rules = [];
private readonly List<(int Low, int High, int Points)> ranges = [];
private readonly bool exchangeIsPoints;
private readonly bool miscIsPoints;
public UdcPoints(string setting)
{
@@ -22,22 +33,51 @@ public sealed class UdcPoints
}
for (int at = 0; at + 1 < parts.Length; at += 2)
{
if (int.TryParse(parts[at + 1].Trim(), out int points))
string condition = parts[at].Trim();
string score = parts[at + 1].Trim();
if (score == "*")
{
rules.Add((parts[at].Trim(), points));
exchangeIsPoints |= condition.Equals("Exchange", StringComparison.OrdinalIgnoreCase);
miscIsPoints |= condition.Equals("Misc", StringComparison.OrdinalIgnoreCase);
}
else if (int.TryParse(score, NumberStyles.Integer, CultureInfo.InvariantCulture, out int points))
{
rules.Add((condition, points));
}
}
ReadRanges(setting);
}
public int For(QsoContext qso)
public int For(QsoContext qso, string mySentExchange)
{
if (rules.Count == 0)
int points = Typed(qso) ?? Ruled(qso, mySentExchange);
if (ranges.Count == 0)
{
return flat;
return points;
}
int km = (int)Math.Round(qso.DistanceKm + 0.5);
int? inRange = RangePoints(km);
return inRange is null ? km : points + inRange.Value;
}
private int? Typed(QsoContext qso)
{
if (exchangeIsPoints && int.TryParse(qso.Qso.Exchange1.Trim(), out int fromExchange))
{
return fromExchange;
}
if (miscIsPoints && int.TryParse(qso.Qso.MiscText.Trim(), out int fromMisc))
{
return fromMisc;
}
return null;
}
private int Ruled(QsoContext qso, string mySentExchange)
{
foreach ((string condition, int points) in rules)
{
if (Tests.Any(test => test(qso, condition)))
if (UdcCondition.Matches(qso, condition, mySentExchange))
{
return points;
}
@@ -45,44 +85,30 @@ public sealed class UdcPoints
return flat;
}
private static readonly Func<QsoContext, string, bool>[] Tests =
[
MatchesBand,
MatchesMode,
MatchesContinent,
MatchesCountry,
];
private static bool MatchesBand(QsoContext qso, string condition) =>
qso.Band is not null &&
condition.EndsWith('m') &&
MeterName(qso.Band).Equals(condition, StringComparison.OrdinalIgnoreCase);
private static bool MatchesMode(QsoContext qso, string condition) => condition.ToUpperInvariant() switch
private int? RangePoints(int km)
{
"CW" => qso.ModeCategory == ModeCategory.Cw,
"SSB" => qso.ModeCategory == ModeCategory.Phone,
"DIGI" => qso.ModeCategory == ModeCategory.Digital,
"RTTY" or "PSK" or "FT8" or "FT4" or "FM" or "AM" =>
qso.Qso.Mode.Name.Equals(condition, StringComparison.OrdinalIgnoreCase),
_ => false,
};
foreach ((int low, int high, int points) in ranges)
{
if (km >= low && km <= high)
{
return points;
}
}
return null;
}
private static bool MatchesContinent(QsoContext qso, string condition) => condition.ToUpperInvariant() switch
private void ReadRanges(string setting)
{
"SAMECONTINENT" => qso.IsSameContinent,
"OTHERCONTINENT" => !qso.IsSameContinent,
"EU" or "NA" or "SA" or "AS" or "AF" or "OC" or "AN" =>
qso.Continent.Equals(condition, StringComparison.OrdinalIgnoreCase),
_ => false,
};
private static bool MatchesCountry(QsoContext qso, string condition) =>
condition.Equals("MyCountry", StringComparison.OrdinalIgnoreCase)
? qso.IsSameCountry
: qso.CountryPrefix.Equals(condition, StringComparison.OrdinalIgnoreCase);
/// The band names UDC files use: `160m`, `80m`, `70cm` and so on, which are
/// the band names lower-cased.
private static string MeterName(Band band) => band.Name.ToLowerInvariant();
foreach (string entry in setting.Split(';', StringSplitOptions.RemoveEmptyEntries))
{
string[] bounds = entry.Split('/');
if (bounds.Length == 3 &&
int.TryParse(bounds[0].Trim(), out int low) &&
int.TryParse(bounds[1].Trim(), out int high) &&
int.TryParse(bounds[2].Trim(), out int points))
{
ranges.Add((low, high, points));
}
}
}
}
@@ -0,0 +1,150 @@
using System.Globalization;
using Nonemm.Core;
namespace Nonemm.Contests.Udc;
/// The `PointsMultBy…` settings, which scale what a contact scores. Each
/// setting is a list of a key and a factor in turn, and the first key that
/// matches the contact gives the factor. The settings multiply together, so a
/// contest can pay double on 160 metres and triple outside the continent at the
/// same time.
///
/// `PointsMultAtTimeGMT` and `PointsMultAtTimeLocal` are a start time, an end
/// time and a factor, both times `HHMM`. A window whose end is before its start
/// runs over midnight.
///
/// `PowerMult` is the operator's own entry: a list of `QRPP`, `QRP`, `LP` or
/// `HP` and a factor, which multiplies the whole score.
///
/// `PointsMultByCategory` is not about the operator's own entry: it scales a
/// contact with a station signing QRP, and its keys are the last three or four
/// characters of that station's callsign — `QRP` and `/QRP`. `PowerMult`,
/// which scales the whole score by the operator's own power category, is in
/// `UserDefinedContest` where the score is added up.
public sealed class UdcPointsMultiplier
{
private readonly List<(string Key, double Factor)> byBand;
private readonly List<(string Key, double Factor)> byMode;
private readonly List<(string Key, double Factor)> byContinent;
private readonly List<(string Key, double Factor)> byCountry;
private readonly List<(int From, int To, double Factor)> atTimeUtc;
private readonly List<(int From, int To, double Factor)> atTimeLocal;
private readonly List<(string Key, double Factor)> byCategory;
private readonly List<(string Key, double Factor)> byPower;
public UdcPointsMultiplier(UdcFile file)
{
byBand = ReadPairs(file.Text("PointsMultByBand"));
byMode = ReadPairs(file.Text("PointsMultByMode"));
byContinent = ReadPairs(file.Text("PointsMultByContinent"));
byCountry = ReadPairs(file.Text("PointsMultByCountry"));
atTimeUtc = ReadWindows(file.Text("PointsMultAtTimeGMT"));
atTimeLocal = ReadWindows(file.Text("PointsMultAtTimeLocal"));
byCategory = ReadPairs(file.Text("PointsMultByCategory"));
byPower = ReadPairs(file.Text("PowerMult"));
}
public double For(QsoContext qso) =>
First(byBand, qso) * First(byMode, qso) * First(byContinent, qso) * First(byCountry, qso) *
InWindow(atTimeUtc, qso.Qso.TimestampUtc) *
InWindow(atTimeLocal, qso.Qso.TimestampUtc.ToLocalTime()) *
ForCategory(qso);
/// What the whole score is multiplied by, from the power category of the
/// entry. The setting names the categories `LP` and `HP`, and the log
/// stores them as `LOW` and `HIGH`.
public double ForEntry(ContestEntry entry)
{
foreach ((string key, double factor) in byPower)
{
string category = key.Trim().ToUpperInvariant() switch
{
"LP" => "LOW",
"HP" => "HIGH",
string other => other,
};
if (category.Equals(entry.PowerCategory.Trim(), StringComparison.OrdinalIgnoreCase))
{
return factor;
}
}
return 1.0;
}
/// Only a station signing QRP is scaled, which is N1MM's rule.
private double ForCategory(QsoContext qso)
{
string call = qso.Qso.Call.Text.ToUpperInvariant();
if (!call.Contains("QRP", StringComparison.Ordinal))
{
return 1.0;
}
foreach ((string key, double factor) in byCategory)
{
if (call.EndsWith(key.Trim(), StringComparison.OrdinalIgnoreCase))
{
return factor;
}
}
return 1.0;
}
private static double First(List<(string Key, double Factor)> pairs, QsoContext qso)
{
foreach ((string key, double factor) in pairs)
{
if (UdcCondition.Matches(qso, key, ""))
{
return factor;
}
}
return 1.0;
}
private static double InWindow(List<(int From, int To, double Factor)> windows, DateTime at)
{
int time = (at.Hour * 100) + at.Minute;
foreach ((int from, int to, double factor) in windows)
{
bool inside = from <= to ? time >= from && time <= to : time >= from || time <= to;
if (inside)
{
return factor;
}
}
return 1.0;
}
private static List<(string, double)> ReadPairs(string setting)
{
string[] parts = setting.Split(',', StringSplitOptions.RemoveEmptyEntries);
List<(string, double)> pairs = [];
for (int at = 0; at + 1 < parts.Length; at += 2)
{
if (TryFactor(parts[at + 1], out double factor))
{
pairs.Add((parts[at].Trim(), factor));
}
}
return pairs;
}
private static List<(int, int, double)> ReadWindows(string setting)
{
string[] parts = setting.Split(',', StringSplitOptions.RemoveEmptyEntries);
List<(int, int, double)> windows = [];
for (int at = 0; at + 2 < parts.Length; at += 3)
{
if (int.TryParse(parts[at].Trim(), out int from) &&
int.TryParse(parts[at + 1].Trim(), out int to) &&
TryFactor(parts[at + 2], out double factor))
{
windows.Add((from, to, factor));
}
}
return windows;
}
private static bool TryFactor(string text, out double factor) =>
double.TryParse(text.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out factor);
}
+87 -18
View File
@@ -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