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:
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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