Add the rest of the major contests, .udc files and the registry
ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP and general logging join CQ WW and CQ WPX. The Cabrillo QSO line is now built column by column by the contest, because Sweepstakes puts the callsign after the serial number where everyone else puts it first. User-defined contests read the subset of N1MM's .udc settings that covers the exchange, the dupe rule, points and up to three multipliers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Nonemm.Contests.Udc;
|
||||
|
||||
/// A `.udc` user-defined contest file: sections of `Key=value` lines. N1MM's
|
||||
/// help writes keys as `/Name/`, and some files are saved that way, so both
|
||||
/// spellings are read.
|
||||
public sealed class UdcFile
|
||||
{
|
||||
private readonly Dictionary<string, string> values;
|
||||
|
||||
private UdcFile(Dictionary<string, string> values) => this.values = values;
|
||||
|
||||
public static UdcFile Parse(string text)
|
||||
{
|
||||
Dictionary<string, string> values = new(StringComparer.OrdinalIgnoreCase);
|
||||
string section = "";
|
||||
foreach (string rawLine in text.Split('\n'))
|
||||
{
|
||||
string line = rawLine.Trim().TrimEnd('\r');
|
||||
if (line.Length == 0 || line.StartsWith(';') || line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith('[') && line.EndsWith(']'))
|
||||
{
|
||||
section = line[1..^1].Trim();
|
||||
continue;
|
||||
}
|
||||
int equals = line.IndexOf('=');
|
||||
if (equals < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string key = line[..equals].Trim().Trim('/').Trim();
|
||||
values[$"{section}.{key}"] = line[(equals + 1)..].Trim();
|
||||
}
|
||||
if (!values.ContainsKey("Contest.Name"))
|
||||
{
|
||||
throw new FormatException("the file has no [Contest] Name, so it is not a .udc contest");
|
||||
}
|
||||
return new UdcFile(values);
|
||||
}
|
||||
|
||||
public string Text(string key, string fallback = "")
|
||||
{
|
||||
string? found = Lookup(key);
|
||||
return string.IsNullOrEmpty(found) || found.Equals("N/A", StringComparison.OrdinalIgnoreCase)
|
||||
? fallback
|
||||
: found;
|
||||
}
|
||||
|
||||
public int Number(string key, int fallback) =>
|
||||
int.TryParse(Lookup(key), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
|
||||
? value
|
||||
: fallback;
|
||||
|
||||
public bool Flag(string key, bool fallback) =>
|
||||
bool.TryParse(Lookup(key), out bool value) ? value : fallback;
|
||||
|
||||
/// A comma-separated value with the spaces trimmed off each part.
|
||||
public IReadOnlyList<string> List(string key) =>
|
||||
Text(key).Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(p => p.Trim())
|
||||
.Where(p => p.Length > 0)
|
||||
.ToList();
|
||||
|
||||
private string? Lookup(string key) =>
|
||||
values.TryGetValue($"Contest.{key}", out string? inContest) ? inContest
|
||||
: values.TryGetValue($"File.{key}", out string? inFile) ? inFile
|
||||
: values.TryGetValue($"Author.{key}", out string? inAuthor) ? inAuthor
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Nonemm.Contests.Udc;
|
||||
|
||||
/// N1MM's `IsMultPer` setting: how often one multiplier value may be counted.
|
||||
public enum UdcMultiplierScope
|
||||
{
|
||||
None = 0,
|
||||
PerBand = 1,
|
||||
PerMode = 2,
|
||||
PerBandAndMode = 3,
|
||||
OncePerContest = 4,
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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.
|
||||
public sealed class UdcMultiplier
|
||||
{
|
||||
private readonly int index;
|
||||
private readonly string source;
|
||||
private readonly UdcMultiplierScope scope;
|
||||
private readonly IReadOnlyList<string> onlyForCountries;
|
||||
|
||||
public UdcMultiplier(
|
||||
int index,
|
||||
string source,
|
||||
UdcMultiplierScope scope,
|
||||
IReadOnlyList<string> onlyForCountries)
|
||||
{
|
||||
this.index = index;
|
||||
this.source = source;
|
||||
this.scope = scope;
|
||||
this.onlyForCountries = onlyForCountries;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
private string? ValueFor(QsoContext qso) => source.ToUpperInvariant() switch
|
||||
{
|
||||
"COUNTRYPREFIX" => qso.CountryPrefix,
|
||||
"WPXPREFIX" => qso.Qso.Call.WpxPrefix(),
|
||||
"SECTION" or "SECT" => Upper(qso.Qso.Section),
|
||||
"EXCHANGE" or "EXCH" => Upper(qso.Qso.Exchange1),
|
||||
"MISC" or "MISCTEXT" => Upper(qso.Qso.MiscText),
|
||||
"CALLSIGN" => qso.Qso.Call.Text,
|
||||
"CQZONE" or "ZN" => qso.Qso.Zone > 0 ? qso.Qso.Zone.ToString() : null,
|
||||
"GRID" => Truncate(qso.Qso.GridSquare, 4),
|
||||
"SGRID" => Truncate(qso.Qso.GridSquare, 6),
|
||||
"FIELD" => Truncate(qso.Qso.GridSquare, 2),
|
||||
"CONTINENT" => qso.Continent,
|
||||
"FIRSTQSO" => "first",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private string ScopeKey(QsoContext qso) => scope switch
|
||||
{
|
||||
UdcMultiplierScope.PerBand => qso.Band?.Name ?? "",
|
||||
UdcMultiplierScope.PerMode => qso.ModeCategory.ToString(),
|
||||
UdcMultiplierScope.PerBandAndMode => $"{qso.Band?.Name}|{qso.ModeCategory}",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static string Upper(string text) => text.Trim().ToUpperInvariant();
|
||||
|
||||
private static string? Truncate(string text, int length)
|
||||
{
|
||||
string trimmed = text.Trim().ToUpperInvariant();
|
||||
return trimmed.Length < length ? null : trimmed[..length];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
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.
|
||||
public sealed class UdcPoints
|
||||
{
|
||||
private readonly int flat;
|
||||
private readonly List<(string Condition, int Points)> rules = [];
|
||||
|
||||
public UdcPoints(string setting)
|
||||
{
|
||||
string[] parts = setting.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length == 1 && int.TryParse(parts[0].Trim(), out int only))
|
||||
{
|
||||
flat = only;
|
||||
return;
|
||||
}
|
||||
for (int at = 0; at + 1 < parts.Length; at += 2)
|
||||
{
|
||||
if (int.TryParse(parts[at + 1].Trim(), out int points))
|
||||
{
|
||||
rules.Add((parts[at].Trim(), points));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int For(QsoContext qso)
|
||||
{
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
return flat;
|
||||
}
|
||||
foreach ((string condition, int points) in rules)
|
||||
{
|
||||
if (Tests.Any(test => test(qso, condition)))
|
||||
{
|
||||
return points;
|
||||
}
|
||||
}
|
||||
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
|
||||
{
|
||||
"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,
|
||||
};
|
||||
|
||||
private static bool MatchesContinent(QsoContext qso, string condition) => condition.ToUpperInvariant() switch
|
||||
{
|
||||
"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();
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Udc;
|
||||
|
||||
/// A contest read from a `.udc` file. The subset of N1MM's settings this reads
|
||||
/// covers the exchange, the dupe rule, points and up to three multipliers;
|
||||
/// settings it does not read are ignored rather than guessed at.
|
||||
public sealed class UserDefinedContest : Contest
|
||||
{
|
||||
private readonly UdcFile file;
|
||||
private readonly UdcPoints points;
|
||||
private readonly IReadOnlyList<UdcMultiplier> multipliers;
|
||||
private readonly IReadOnlyList<ExchangeField> exchangeFields;
|
||||
|
||||
public UserDefinedContest(UdcFile file)
|
||||
{
|
||||
this.file = file;
|
||||
points = new UdcPoints(file.Text("PointsPerContact", "1"));
|
||||
multipliers = ReadMultipliers(file);
|
||||
exchangeFields = ReadExchangeFields(file);
|
||||
}
|
||||
|
||||
public static UserDefinedContest Load(string path) =>
|
||||
new(UdcFile.Parse(File.ReadAllText(path)));
|
||||
|
||||
public string Name => file.Text("Name").ToUpperInvariant();
|
||||
|
||||
public string DisplayName => file.Text("DisplayName", Name);
|
||||
|
||||
public string CabrilloName => file.Text("CabrilloName", Name);
|
||||
|
||||
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) => exchangeFields;
|
||||
|
||||
public IReadOnlyList<string> MultiplierNames =>
|
||||
new[] { "Multiplier1Name", "Multiplier2Name", "Multiplier3Name" }
|
||||
.Select(key => file.Text(key))
|
||||
.Where(name => name.Length > 0)
|
||||
.ToList();
|
||||
|
||||
public DupeScope DupeScope => file.Number("DupeType", 2) switch
|
||||
{
|
||||
1 => DupeScope.Once,
|
||||
3 => DupeScope.PerBandAndMode,
|
||||
4 => DupeScope.Never,
|
||||
_ => DupeScope.PerBand,
|
||||
};
|
||||
|
||||
public bool HasSerialNumbers =>
|
||||
file.Text("DefaultContestExchange").StartsWith("001", StringComparison.Ordinal);
|
||||
|
||||
public IReadOnlyList<ModeCategory> Modes => file.Text("Mode", "CW").ToUpperInvariant() switch
|
||||
{
|
||||
"CW" => [ModeCategory.Cw],
|
||||
"SSB" => [ModeCategory.Phone],
|
||||
"RTTY" => [ModeCategory.Digital],
|
||||
_ => [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital],
|
||||
};
|
||||
|
||||
public string SentExchangeFor(StationInfo me) => file.Text("DefaultContestExchange", "599");
|
||||
|
||||
public int PointsFor(QsoContext qso) => points.For(qso);
|
||||
|
||||
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso) =>
|
||||
multipliers.Select(m => m.For(qso)).OfType<Multiplier>().ToList();
|
||||
|
||||
public int TotalScore(ScoreTally tally) =>
|
||||
multipliers.Count == 0 ? tally.Points : tally.Points * tally.TotalMultipliers;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
[
|
||||
new CabrilloField(me.Callsign, 13),
|
||||
new CabrilloField(qso.SentReport, 3),
|
||||
new CabrilloField(SentExchangePart(qso), 6),
|
||||
],
|
||||
[
|
||||
new CabrilloField(qso.Call.Text, 13),
|
||||
new CabrilloField(qso.ReceivedReport, 3),
|
||||
new CabrilloField(ReceivedExchangePart(qso), 6),
|
||||
]);
|
||||
|
||||
private string SentExchangePart(Qso qso) =>
|
||||
HasSerialNumbers ? $"{qso.SentNumber:000}" : file.Text("DefaultContestExchange");
|
||||
|
||||
private static string ReceivedExchangePart(Qso qso) =>
|
||||
qso.Exchange1.Length > 0 ? qso.Exchange1
|
||||
: qso.Section.Length > 0 ? qso.Section
|
||||
: qso.ReceivedNumber > 0 ? $"{qso.ReceivedNumber:000}"
|
||||
: qso.MiscText;
|
||||
|
||||
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)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/// `EntryWindowInfo` names the boxes; `FrameText` labels them. Only the
|
||||
/// received boxes go in the exchange, because the sent side is fixed for
|
||||
/// the whole contest.
|
||||
private static IReadOnlyList<ExchangeField> ReadExchangeFields(UdcFile file)
|
||||
{
|
||||
string[] labels = file.Text("FrameText").Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
List<ExchangeField> fields = [];
|
||||
int labelAt = 0;
|
||||
foreach (string box in file.List("EntryWindowInfo").Where(p => !p.All(char.IsAsciiDigit)))
|
||||
{
|
||||
ExchangeField? field = BoxToField(box, labels.Length > labelAt ? labels[labelAt] : box);
|
||||
labelAt++;
|
||||
if (field is not null)
|
||||
{
|
||||
fields.Add(field);
|
||||
}
|
||||
}
|
||||
return fields.Count > 0
|
||||
? fields
|
||||
: [new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report)];
|
||||
}
|
||||
|
||||
private static ExchangeField? BoxToField(string box, string label) => box.ToUpperInvariant() switch
|
||||
{
|
||||
"RCVTEXT" => new ExchangeField(label, ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
|
||||
"RCVNRTEXT" => new ExchangeField(label, ExchangeSlot.SerialNumber, ExchangeFieldKind.Number),
|
||||
"EXCHANGE1TEXT" => new ExchangeField(label, ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 },
|
||||
"SECTTEXT" => new ExchangeField(label, ExchangeSlot.Section, ExchangeFieldKind.ArrlSection),
|
||||
"GRIDSQUARETEXT" => new ExchangeField(label, ExchangeSlot.GridSquare, ExchangeFieldKind.Grid),
|
||||
"NAMETEXT" => new ExchangeField(label, ExchangeSlot.Name, ExchangeFieldKind.Text) { Width = 10 },
|
||||
"MISCTEXT" => new ExchangeField(label, ExchangeSlot.MiscText, ExchangeFieldKind.Text) { Width = 8 },
|
||||
"COMMENTTEXT" => new ExchangeField(label, ExchangeSlot.Comment, ExchangeFieldKind.Text, IsRequired: false) { Width = 20 },
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user