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:
@@ -4,8 +4,9 @@ namespace Nonemm.Contests;
|
||||
/// carries the width it is padded to.
|
||||
public sealed record CabrilloField(string Value, int Width);
|
||||
|
||||
/// The exchange columns of one Cabrillo QSO line, in the order the sponsor's
|
||||
/// template lists them.
|
||||
/// The columns of one Cabrillo QSO line after the time, sent group first. The
|
||||
/// callsign is one of the columns because sponsors do not agree on where it
|
||||
/// goes: most put it first, Sweepstakes puts it last.
|
||||
public sealed record CabrilloExchange(
|
||||
IReadOnlyList<CabrilloField> Sent,
|
||||
IReadOnlyList<CabrilloField> Received);
|
||||
|
||||
@@ -14,7 +14,9 @@ public interface Contest
|
||||
/// The name the sponsor's Cabrillo header asks for.
|
||||
string CabrilloName { get; }
|
||||
|
||||
IReadOnlyList<ExchangeField> ExchangeFields { get; }
|
||||
/// The boxes the entry window shows. ARRL DX and others ask for a
|
||||
/// different exchange depending on where the operator is.
|
||||
IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me);
|
||||
|
||||
/// Up to three names, in the order the score summary shows them.
|
||||
IReadOnlyList<string> MultiplierNames { get; }
|
||||
|
||||
10
src/Nonemm.Contests/ContestChoice.cs
Normal file
10
src/Nonemm.Contests/ContestChoice.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// A contest the operator can pick, and the modes it runs in.
|
||||
public sealed record ContestChoice(
|
||||
string Name,
|
||||
string DisplayName,
|
||||
IReadOnlyList<ModeCategory> Modes,
|
||||
Func<ModeCategory, Contest> Create);
|
||||
@@ -34,7 +34,7 @@ public sealed class ContestLog
|
||||
public Verdict Judge(Qso candidate)
|
||||
{
|
||||
QsoContext context = ContextFor(candidate);
|
||||
if (workedKeys.Contains(DupeKey(candidate)))
|
||||
if (contest.DupeScope != DupeScope.Never && workedKeys.Contains(DupeKey(candidate)))
|
||||
{
|
||||
return Verdict.Dupe;
|
||||
}
|
||||
@@ -84,7 +84,8 @@ public sealed class ContestLog
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
public bool IsWorked(Qso candidate) => workedKeys.Contains(DupeKey(candidate));
|
||||
public bool IsWorked(Qso candidate) =>
|
||||
contest.DupeScope != DupeScope.Never && workedKeys.Contains(DupeKey(candidate));
|
||||
|
||||
/// Every contact with this call, newest first.
|
||||
public IReadOnlyList<Qso> WorkedBefore(string call) =>
|
||||
|
||||
77
src/Nonemm.Contests/ContestRegistry.cs
Normal file
77
src/Nonemm.Contests/ContestRegistry.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Nonemm.Contests.Rules;
|
||||
using Nonemm.Contests.Udc;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// The contests on offer: the ones written into the program, plus whatever
|
||||
/// `.udc` files the operator has put in the user-defined contests folder.
|
||||
public sealed class ContestRegistry
|
||||
{
|
||||
private static readonly IReadOnlyList<ContestChoice> BuiltIn =
|
||||
[
|
||||
new("CQWW", "CQ World Wide DX", [ModeCategory.Cw, ModeCategory.Phone], m => new CqWorldWide(m)),
|
||||
new("CQWPX", "CQ WPX", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new CqWpx(m)),
|
||||
new("ARRLDX", "ARRL International DX", [ModeCategory.Cw, ModeCategory.Phone], m => new ArrlDx(m)),
|
||||
new("IARUHF", "IARU HF World Championship", [ModeCategory.Cw, ModeCategory.Phone], _ => new IaruHf()),
|
||||
new("SS", "ARRL Sweepstakes", [ModeCategory.Cw, ModeCategory.Phone], m => new Sweepstakes(m)),
|
||||
new("ARRLRTTY", "ARRL RTTY Roundup", [ModeCategory.Digital], _ => new RttyRoundup()),
|
||||
new("NAQP", "North American QSO Party", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new NorthAmericanQsoParty(m)),
|
||||
new("DX", "General logging", [], _ => new GeneralLogging()),
|
||||
];
|
||||
|
||||
private readonly Dictionary<string, ContestChoice> byName;
|
||||
|
||||
private ContestRegistry(Dictionary<string, ContestChoice> byName) => this.byName = byName;
|
||||
|
||||
public static ContestRegistry Create(IEnumerable<UdcFile>? userDefined = null)
|
||||
{
|
||||
Dictionary<string, ContestChoice> byName = BuiltIn.ToDictionary(
|
||||
c => c.Name,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
foreach (UdcFile file in userDefined ?? [])
|
||||
{
|
||||
UserDefinedContest contest = new(file);
|
||||
byName[contest.Name] = new ContestChoice(
|
||||
contest.Name,
|
||||
contest.DisplayName,
|
||||
contest.Modes,
|
||||
_ => new UserDefinedContest(file));
|
||||
}
|
||||
return new ContestRegistry(byName);
|
||||
}
|
||||
|
||||
/// Reads every `.udc` file in the folder. A file that will not parse is
|
||||
/// reported rather than silently left out.
|
||||
public static ContestRegistry FromFolder(string folder, out IReadOnlyList<string> problems)
|
||||
{
|
||||
List<UdcFile> files = [];
|
||||
List<string> failures = [];
|
||||
if (Directory.Exists(folder))
|
||||
{
|
||||
foreach (string path in Directory.EnumerateFiles(folder, "*.udc"))
|
||||
{
|
||||
try
|
||||
{
|
||||
files.Add(UdcFile.Parse(File.ReadAllText(path)));
|
||||
}
|
||||
catch (Exception e) when (e is FormatException or IOException)
|
||||
{
|
||||
failures.Add($"{path}: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
problems = failures;
|
||||
return Create(files);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ContestChoice> Choices =>
|
||||
byName.Values.OrderBy(c => c.DisplayName, StringComparer.Ordinal).ToList();
|
||||
|
||||
public Contest Create(string name, ModeCategory mode) =>
|
||||
byName.TryGetValue(name, out ContestChoice? choice)
|
||||
? choice.Create(mode)
|
||||
: throw new KeyNotFoundException($"no contest named '{name}'");
|
||||
|
||||
public bool Has(string name) => byName.ContainsKey(name);
|
||||
}
|
||||
@@ -14,4 +14,8 @@ public enum DupeScope
|
||||
|
||||
/// Once per mode, whatever the band.
|
||||
PerMode,
|
||||
|
||||
/// Never a dupe: the same station may be worked again straight away, which
|
||||
/// the sprint contests allow.
|
||||
Never,
|
||||
}
|
||||
|
||||
22
src/Nonemm.Contests/Multipliers/ArrlSections.cs
Normal file
22
src/Nonemm.Contests/Multipliers/ArrlSections.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace Nonemm.Contests.Multipliers;
|
||||
|
||||
/// The ARRL and RAC sections, which Sweepstakes and Field Day count.
|
||||
public static class ArrlSections
|
||||
{
|
||||
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"CT", "EMA", "ME", "NH", "RI", "VT", "WMA",
|
||||
"ENY", "NLI", "NNJ", "NNY", "SNJ", "WNY",
|
||||
"DE", "EPA", "MDC", "WPA",
|
||||
"AL", "GA", "KY", "NC", "NFL", "SC", "SFL", "TN", "VA", "WCF", "PR", "VI",
|
||||
"AR", "LA", "MS", "NM", "NTX", "OK", "STX", "WTX",
|
||||
"EB", "LAX", "ORG", "SB", "SCV", "SDG", "SF", "SJV", "SV", "PAC",
|
||||
"AK", "AZ", "EWA", "ID", "MT", "NV", "OR", "UT", "WWA", "WY",
|
||||
"MI", "OH", "WV",
|
||||
"IL", "IN", "WI",
|
||||
"CO", "IA", "KS", "MN", "MO", "ND", "NE", "SD",
|
||||
"AB", "BC", "GH", "MB", "NB", "NL", "NS", "ONE", "ONN", "ONS", "PE", "QC", "SK", "TER",
|
||||
};
|
||||
|
||||
public static bool IsSection(string text) => All.Contains(text.Trim());
|
||||
}
|
||||
32
src/Nonemm.Contests/Multipliers/StatesAndProvinces.cs
Normal file
32
src/Nonemm.Contests/Multipliers/StatesAndProvinces.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace Nonemm.Contests.Multipliers;
|
||||
|
||||
/// US states and Canadian provinces, in the groupings contests count.
|
||||
public static class StatesAndProvinces
|
||||
{
|
||||
/// The 48 contiguous states.
|
||||
public static readonly IReadOnlySet<string> ContiguousStates = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"AL", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "ID", "IL", "IN", "IA",
|
||||
"KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV",
|
||||
"NH", "NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD",
|
||||
"TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY",
|
||||
};
|
||||
|
||||
public static readonly IReadOnlySet<string> AllStates =
|
||||
new HashSet<string>(ContiguousStates.Concat(["AK", "HI"]), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// The 13 Canadian provinces and territories.
|
||||
public static readonly IReadOnlySet<string> Provinces = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"NS", "QC", "ON", "MB", "SK", "AB", "BC", "NT", "NB", "NL", "PE", "YT", "NU",
|
||||
};
|
||||
|
||||
/// What ARRL DX counts for a DX station: the contiguous states, the District
|
||||
/// of Columbia, and the Canadian provinces and territories.
|
||||
public static readonly IReadOnlySet<string> ArrlDxMultipliers =
|
||||
new HashSet<string>(ContiguousStates.Concat(["DC"]).Concat(Provinces), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsStateOrProvince(string text) =>
|
||||
AllStates.Contains(text.Trim()) || Provinces.Contains(text.Trim()) ||
|
||||
text.Trim().Equals("DC", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
92
src/Nonemm.Contests/Rules/ArrlDx.cs
Normal file
92
src/Nonemm.Contests/Rules/ArrlDx.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using Nonemm.Contests.Multipliers;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Rules;
|
||||
|
||||
/// ARRL International DX, CW and SSB. W and VE stations work DX and nobody
|
||||
/// else, so the exchange and the multiplier both depend on which side the
|
||||
/// operator is on.
|
||||
public sealed class ArrlDx : Contest
|
||||
{
|
||||
private readonly ModeCategory mode;
|
||||
|
||||
public ArrlDx(ModeCategory mode) => this.mode = mode;
|
||||
|
||||
public string Name => "ARRLDX";
|
||||
|
||||
public string DisplayName => $"ARRL International DX {ModeLabel()}";
|
||||
|
||||
public string CabrilloName => mode == ModeCategory.Cw ? "ARRL-DX-CW" : "ARRL-DX-SSB";
|
||||
|
||||
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
|
||||
[
|
||||
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
|
||||
IsNorthAmericanHome(me)
|
||||
? new ExchangeField("Pwr", ExchangeSlot.Exchange1, ExchangeFieldKind.Power)
|
||||
: new ExchangeField("S/P", ExchangeSlot.Exchange1, ExchangeFieldKind.UsStateOrCanadianProvince),
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> MultiplierNames => ["Mults"];
|
||||
|
||||
public DupeScope DupeScope => DupeScope.PerBand;
|
||||
|
||||
public bool HasSerialNumbers => false;
|
||||
|
||||
public IReadOnlyList<ModeCategory> Modes => [mode];
|
||||
|
||||
public string SentExchangeFor(StationInfo me) =>
|
||||
IsNorthAmericanHome(me)
|
||||
? $"{DefaultReport()} {StateOrProvince(me)}"
|
||||
: $"{DefaultReport()} {me.Power}";
|
||||
|
||||
/// Only contacts across the W/VE line count, so a DX station working DX or
|
||||
/// a W station working W scores nothing.
|
||||
public int PointsFor(QsoContext qso) =>
|
||||
IsNorthAmericanHome(qso.Me) == IsNorthAmerican(qso.CountryPrefix) ? 0 : 3;
|
||||
|
||||
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso)
|
||||
{
|
||||
if (PointsFor(qso) == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
string band = qso.Band?.Name ?? "";
|
||||
if (IsNorthAmericanHome(qso.Me))
|
||||
{
|
||||
return qso.CountryPrefix.Length == 0 ? [] : [new Multiplier(1, qso.CountryPrefix, band)];
|
||||
}
|
||||
string received = qso.Qso.Exchange1.Trim().ToUpperInvariant();
|
||||
return StatesAndProvinces.ArrlDxMultipliers.Contains(received)
|
||||
? [new Multiplier(1, received, band)]
|
||||
: [];
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
[
|
||||
new CabrilloField(me.Callsign, 13),
|
||||
new CabrilloField(qso.SentReport, 3),
|
||||
new CabrilloField(IsNorthAmericanHome(me) ? StateOrProvince(me) : me.Power, 6),
|
||||
],
|
||||
[
|
||||
new CabrilloField(qso.Call.Text, 13),
|
||||
new CabrilloField(qso.ReceivedReport, 3),
|
||||
new CabrilloField(qso.Exchange1, 6),
|
||||
]);
|
||||
|
||||
private static bool IsNorthAmericanHome(StationInfo me) => IsNorthAmerican(me.CountryPrefix);
|
||||
|
||||
/// The contest's W/VE side is the 48 states, DC and Canada; KH6 and KL7
|
||||
/// count as DX for it.
|
||||
private static bool IsNorthAmerican(string countryPrefix) =>
|
||||
countryPrefix is "K" or "VE";
|
||||
|
||||
private static string StateOrProvince(StationInfo me) =>
|
||||
me.State.Length > 0 ? me.State : me.Province;
|
||||
|
||||
private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB";
|
||||
|
||||
private string DefaultReport() => mode == ModeCategory.Cw ? "599" : "59";
|
||||
}
|
||||
@@ -17,7 +17,7 @@ public sealed class CqWorldWide : Contest
|
||||
|
||||
public string CabrilloName => mode == ModeCategory.Cw ? "CQ-WW-CW" : "CQ-WW-SSB";
|
||||
|
||||
public IReadOnlyList<ExchangeField> ExchangeFields =>
|
||||
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
|
||||
[
|
||||
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
|
||||
new ExchangeField("Zone", ExchangeSlot.Zone, ExchangeFieldKind.CqZone),
|
||||
@@ -70,8 +70,16 @@ public sealed class CqWorldWide : Contest
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
[new CabrilloField(qso.SentReport, 3), new CabrilloField(me.CqZone.ToString(), 6)],
|
||||
[new CabrilloField(qso.ReceivedReport, 3), new CabrilloField(qso.Zone.ToString(), 6)]);
|
||||
[
|
||||
new CabrilloField(me.Callsign, 13),
|
||||
new CabrilloField(qso.SentReport, 3),
|
||||
new CabrilloField(me.CqZone.ToString(), 6),
|
||||
],
|
||||
[
|
||||
new CabrilloField(qso.Call.Text, 13),
|
||||
new CabrilloField(qso.ReceivedReport, 3),
|
||||
new CabrilloField(qso.Zone.ToString(), 6),
|
||||
]);
|
||||
|
||||
private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB";
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class CqWpx : Contest
|
||||
_ => "CQ-WPX-RTTY",
|
||||
};
|
||||
|
||||
public IReadOnlyList<ExchangeField> ExchangeFields =>
|
||||
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
|
||||
[
|
||||
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
|
||||
new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number),
|
||||
@@ -71,8 +71,16 @@ public sealed class CqWpx : Contest
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
[new CabrilloField(qso.SentReport, 3), new CabrilloField($"{qso.SentNumber:0000}", 6)],
|
||||
[new CabrilloField(qso.ReceivedReport, 3), new CabrilloField($"{qso.ReceivedNumber:0000}", 6)]);
|
||||
[
|
||||
new CabrilloField(me.Callsign, 13),
|
||||
new CabrilloField(qso.SentReport, 3),
|
||||
new CabrilloField($"{qso.SentNumber:0000}", 6),
|
||||
],
|
||||
[
|
||||
new CabrilloField(qso.Call.Text, 13),
|
||||
new CabrilloField(qso.ReceivedReport, 3),
|
||||
new CabrilloField($"{qso.ReceivedNumber:0000}", 6),
|
||||
]);
|
||||
|
||||
private string ModeLabel() => mode switch
|
||||
{
|
||||
|
||||
42
src/Nonemm.Contests/Rules/GeneralLogging.cs
Normal file
42
src/Nonemm.Contests/Rules/GeneralLogging.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Rules;
|
||||
|
||||
/// Everyday logging outside a contest: a report either way, no score and no
|
||||
/// dupe check beyond telling the operator this station is in the log already.
|
||||
public sealed class GeneralLogging : Contest
|
||||
{
|
||||
public string Name => "DX";
|
||||
|
||||
public string DisplayName => "General logging";
|
||||
|
||||
public string CabrilloName => "DX";
|
||||
|
||||
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
|
||||
[
|
||||
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
|
||||
new ExchangeField("Name", ExchangeSlot.Name, ExchangeFieldKind.Text, IsRequired: false) { Width = 10 },
|
||||
new ExchangeField("Comment", ExchangeSlot.Comment, ExchangeFieldKind.Text, IsRequired: false) { Width = 20 },
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> MultiplierNames => [];
|
||||
|
||||
public DupeScope DupeScope => DupeScope.PerBandAndMode;
|
||||
|
||||
public bool HasSerialNumbers => false;
|
||||
|
||||
public IReadOnlyList<ModeCategory> Modes => [];
|
||||
|
||||
public string SentExchangeFor(StationInfo me) => "599";
|
||||
|
||||
public int PointsFor(QsoContext qso) => 0;
|
||||
|
||||
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso) => [];
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Qsos;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
[new CabrilloField(me.Callsign, 13), new CabrilloField(qso.SentReport, 3)],
|
||||
[new CabrilloField(qso.Call.Text, 13), new CabrilloField(qso.ReceivedReport, 3)]);
|
||||
}
|
||||
75
src/Nonemm.Contests/Rules/IaruHf.cs
Normal file
75
src/Nonemm.Contests/Rules/IaruHf.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Rules;
|
||||
|
||||
/// IARU HF World Championship. The exchange is an ITU zone or, for a society
|
||||
/// headquarters station, its abbreviation; both count as multipliers per band.
|
||||
public sealed class IaruHf : Contest
|
||||
{
|
||||
public string Name => "IARUHF";
|
||||
|
||||
public string DisplayName => "IARU HF World Championship";
|
||||
|
||||
public string CabrilloName => "IARU-HF";
|
||||
|
||||
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
|
||||
[
|
||||
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
|
||||
new ExchangeField("Zone/HQ", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 },
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> MultiplierNames => ["Zones", "HQ"];
|
||||
|
||||
public DupeScope DupeScope => DupeScope.PerBandAndMode;
|
||||
|
||||
public bool HasSerialNumbers => false;
|
||||
|
||||
public IReadOnlyList<ModeCategory> Modes => [ModeCategory.Cw, ModeCategory.Phone];
|
||||
|
||||
public string SentExchangeFor(StationInfo me) => $"599 {me.ItuZone}";
|
||||
|
||||
public int PointsFor(QsoContext qso)
|
||||
{
|
||||
string exchange = Exchange(qso);
|
||||
if (!IsZone(exchange))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
if (int.TryParse(exchange, out int zone) && zone == qso.Me.ItuZone)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
return qso.IsSameContinent ? 3 : 5;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso)
|
||||
{
|
||||
string exchange = Exchange(qso);
|
||||
if (exchange.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
string band = qso.Band?.Name ?? "";
|
||||
return [new Multiplier(IsZone(exchange) ? 1 : 2, exchange, band)];
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
[
|
||||
new CabrilloField(me.Callsign, 13),
|
||||
new CabrilloField(qso.SentReport, 3),
|
||||
new CabrilloField(me.ItuZone.ToString(), 6),
|
||||
],
|
||||
[
|
||||
new CabrilloField(qso.Call.Text, 13),
|
||||
new CabrilloField(qso.ReceivedReport, 3),
|
||||
new CabrilloField(qso.Exchange1, 6),
|
||||
]);
|
||||
|
||||
private static string Exchange(QsoContext qso) => qso.Qso.Exchange1.Trim().ToUpperInvariant();
|
||||
|
||||
private static bool IsZone(string exchange) =>
|
||||
exchange.Length > 0 && exchange.All(char.IsAsciiDigit);
|
||||
}
|
||||
78
src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs
Normal file
78
src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using Nonemm.Contests.Multipliers;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Rules;
|
||||
|
||||
/// North American QSO Party. Name and location are exchanged, a contact is one
|
||||
/// point, and states, provinces and North American countries count once a band.
|
||||
public sealed class NorthAmericanQsoParty : Contest
|
||||
{
|
||||
private readonly ModeCategory mode;
|
||||
|
||||
public NorthAmericanQsoParty(ModeCategory mode) => this.mode = mode;
|
||||
|
||||
public string Name => mode switch
|
||||
{
|
||||
ModeCategory.Cw => "NAQPCW",
|
||||
ModeCategory.Phone => "NAQPSSB",
|
||||
_ => "NAQPRTTY",
|
||||
};
|
||||
|
||||
public string DisplayName => $"North American QSO Party {ModeLabel()}";
|
||||
|
||||
public string CabrilloName => $"NAQP-{ModeLabel()}";
|
||||
|
||||
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
|
||||
[
|
||||
new ExchangeField("Name", ExchangeSlot.Name, ExchangeFieldKind.Text) { Width = 10 },
|
||||
new ExchangeField("S/P/C", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 },
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> MultiplierNames => ["Mults"];
|
||||
|
||||
public DupeScope DupeScope => DupeScope.PerBand;
|
||||
|
||||
public bool HasSerialNumbers => false;
|
||||
|
||||
public IReadOnlyList<ModeCategory> Modes => [mode];
|
||||
|
||||
public string SentExchangeFor(StationInfo me) =>
|
||||
$"{me.Name} {(me.State.Length > 0 ? me.State : me.CountryPrefix)}";
|
||||
|
||||
public int PointsFor(QsoContext qso) => 1;
|
||||
|
||||
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso)
|
||||
{
|
||||
string band = qso.Band?.Name ?? "";
|
||||
string exchange = qso.Qso.Exchange1.Trim().ToUpperInvariant();
|
||||
if (StatesAndProvinces.IsStateOrProvince(exchange))
|
||||
{
|
||||
return [new Multiplier(1, exchange, band)];
|
||||
}
|
||||
return qso.Continent == "NA" && qso.CountryPrefix.Length > 0
|
||||
? [new Multiplier(1, qso.CountryPrefix, band)]
|
||||
: [];
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
[
|
||||
new CabrilloField(me.Callsign, 13),
|
||||
new CabrilloField(me.Name, 10),
|
||||
new CabrilloField(me.State.Length > 0 ? me.State : me.CountryPrefix, 6),
|
||||
],
|
||||
[
|
||||
new CabrilloField(qso.Call.Text, 13),
|
||||
new CabrilloField(qso.Name, 10),
|
||||
new CabrilloField(qso.Exchange1, 6),
|
||||
]);
|
||||
|
||||
private string ModeLabel() => mode switch
|
||||
{
|
||||
ModeCategory.Cw => "CW",
|
||||
ModeCategory.Phone => "SSB",
|
||||
_ => "RTTY",
|
||||
};
|
||||
}
|
||||
61
src/Nonemm.Contests/Rules/RttyRoundup.cs
Normal file
61
src/Nonemm.Contests/Rules/RttyRoundup.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using Nonemm.Contests.Multipliers;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Rules;
|
||||
|
||||
/// ARRL RTTY Roundup. One point a contact; states, provinces and DXCC entities
|
||||
/// each count once for the whole contest.
|
||||
public sealed class RttyRoundup : Contest
|
||||
{
|
||||
public string Name => "ARRLRTTY";
|
||||
|
||||
public string DisplayName => "ARRL RTTY Roundup";
|
||||
|
||||
public string CabrilloName => "ARRL-RTTY";
|
||||
|
||||
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
|
||||
[
|
||||
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
|
||||
new ExchangeField("S/P/Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 },
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> MultiplierNames => ["Mults"];
|
||||
|
||||
public DupeScope DupeScope => DupeScope.PerBand;
|
||||
|
||||
public bool HasSerialNumbers => true;
|
||||
|
||||
public IReadOnlyList<ModeCategory> Modes => [ModeCategory.Digital];
|
||||
|
||||
public string SentExchangeFor(StationInfo me) =>
|
||||
me.State.Length > 0 ? $"599 {me.State}" : "599";
|
||||
|
||||
public int PointsFor(QsoContext qso) => 1;
|
||||
|
||||
/// A North American station sends a state or province and counts as that;
|
||||
/// everyone else counts as their DXCC entity.
|
||||
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso)
|
||||
{
|
||||
string exchange = qso.Qso.Exchange1.Trim().ToUpperInvariant();
|
||||
if (StatesAndProvinces.IsStateOrProvince(exchange))
|
||||
{
|
||||
return [new Multiplier(1, exchange, "")];
|
||||
}
|
||||
return qso.CountryPrefix.Length == 0 ? [] : [new Multiplier(1, qso.CountryPrefix, "")];
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
[
|
||||
new CabrilloField(me.Callsign, 13),
|
||||
new CabrilloField(qso.SentReport, 3),
|
||||
new CabrilloField(me.State.Length > 0 ? me.State : $"{qso.SentNumber}", 6),
|
||||
],
|
||||
[
|
||||
new CabrilloField(qso.Call.Text, 13),
|
||||
new CabrilloField(qso.ReceivedReport, 3),
|
||||
new CabrilloField(qso.Exchange1, 6),
|
||||
]);
|
||||
}
|
||||
69
src/Nonemm.Contests/Rules/Sweepstakes.cs
Normal file
69
src/Nonemm.Contests/Rules/Sweepstakes.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using Nonemm.Contests.Multipliers;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Rules;
|
||||
|
||||
/// ARRL November Sweepstakes. A station counts once for the whole contest
|
||||
/// whatever the band, and the multiplier is the ARRL or RAC section.
|
||||
public sealed class Sweepstakes : Contest
|
||||
{
|
||||
private readonly ModeCategory mode;
|
||||
|
||||
public Sweepstakes(ModeCategory mode) => this.mode = mode;
|
||||
|
||||
public string Name => "SS";
|
||||
|
||||
public string DisplayName => $"ARRL Sweepstakes {ModeLabel()}";
|
||||
|
||||
public string CabrilloName => mode == ModeCategory.Cw ? "ARRL-SS-CW" : "ARRL-SS-SSB";
|
||||
|
||||
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
|
||||
[
|
||||
new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number),
|
||||
new ExchangeField("Prec", ExchangeSlot.Precedence, ExchangeFieldKind.Precedence),
|
||||
new ExchangeField("Ck", ExchangeSlot.Check, ExchangeFieldKind.Check),
|
||||
new ExchangeField("Sec", ExchangeSlot.Section, ExchangeFieldKind.ArrlSection),
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> MultiplierNames => ["Sections"];
|
||||
|
||||
public DupeScope DupeScope => DupeScope.Once;
|
||||
|
||||
public bool HasSerialNumbers => true;
|
||||
|
||||
public IReadOnlyList<ModeCategory> Modes => [mode];
|
||||
|
||||
public string SentExchangeFor(StationInfo me) =>
|
||||
$"{me.Precedence} {me.Callsign} {me.Check:00} {me.ArrlSection}";
|
||||
|
||||
public int PointsFor(QsoContext qso) => 2;
|
||||
|
||||
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso)
|
||||
{
|
||||
string section = qso.Qso.Section.Trim().ToUpperInvariant();
|
||||
return ArrlSections.IsSection(section) ? [new Multiplier(1, section, "")] : [];
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
/// Sweepstakes puts the callsign after the serial number and precedence
|
||||
/// rather than first, which is why the writer takes the whole column list.
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
[
|
||||
new CabrilloField($"{qso.SentNumber}", 4),
|
||||
new CabrilloField(me.Precedence, 1),
|
||||
new CabrilloField(me.Callsign, 13),
|
||||
new CabrilloField($"{me.Check:00}", 2),
|
||||
new CabrilloField(me.ArrlSection, 3),
|
||||
],
|
||||
[
|
||||
new CabrilloField($"{qso.ReceivedNumber}", 4),
|
||||
new CabrilloField(qso.Precedence, 1),
|
||||
new CabrilloField(qso.Call.Text, 13),
|
||||
new CabrilloField($"{qso.Check:00}", 2),
|
||||
new CabrilloField(qso.Section, 3),
|
||||
]);
|
||||
|
||||
private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB";
|
||||
}
|
||||
73
src/Nonemm.Contests/Udc/UdcFile.cs
Normal file
73
src/Nonemm.Contests/Udc/UdcFile.cs
Normal file
@@ -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;
|
||||
}
|
||||
11
src/Nonemm.Contests/Udc/UdcMultiplierScope.cs
Normal file
11
src/Nonemm.Contests/Udc/UdcMultiplierScope.cs
Normal file
@@ -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,
|
||||
}
|
||||
73
src/Nonemm.Contests/Udc/UdcMultipliers.cs
Normal file
73
src/Nonemm.Contests/Udc/UdcMultipliers.cs
Normal file
@@ -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];
|
||||
}
|
||||
}
|
||||
88
src/Nonemm.Contests/Udc/UdcPoints.cs
Normal file
88
src/Nonemm.Contests/Udc/UdcPoints.cs
Normal file
@@ -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();
|
||||
}
|
||||
150
src/Nonemm.Contests/Udc/UserDefinedContest.cs
Normal file
150
src/Nonemm.Contests/Udc/UserDefinedContest.cs
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,13 @@ public sealed record StationInfo
|
||||
|
||||
public string Club { get; init; } = "";
|
||||
|
||||
/// The last two digits of the year first licensed, which Sweepstakes calls
|
||||
/// the check.
|
||||
public int Check { get; init; }
|
||||
|
||||
/// The Sweepstakes precedence letter for the entry: Q, A, B, U, M or S.
|
||||
public string Precedence { get; init; } = "";
|
||||
|
||||
public double Latitude { get; init; }
|
||||
|
||||
public double Longitude { get; init; }
|
||||
|
||||
@@ -68,9 +68,7 @@ public sealed class CabrilloWriter
|
||||
line.Append(qso.Mode.CabrilloCode.PadRight(2)).Append(' ');
|
||||
line.Append(qso.TimestampUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append(' ');
|
||||
line.Append(qso.TimestampUtc.ToString("HHmm", CultureInfo.InvariantCulture)).Append(' ');
|
||||
line.Append(me.Callsign.PadRight(13)).Append(' ');
|
||||
Append(line, exchange.Sent);
|
||||
line.Append(qso.Call.Text.PadRight(13)).Append(' ');
|
||||
Append(line, exchange.Received);
|
||||
line.Append(qso.RadioNumber > 1 ? '1' : '0');
|
||||
return line.ToString();
|
||||
|
||||
55
tests/Nonemm.Contests.Tests/ArrlDxTests.cs
Normal file
55
tests/Nonemm.Contests.Tests/ArrlDxTests.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using Nonemm.Contests.Rules;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Tests;
|
||||
|
||||
public class ArrlDxTests
|
||||
{
|
||||
private static ContestLog LogFor(StationInfo me) =>
|
||||
new(new ArrlDx(ModeCategory.Cw), me, TestLog.CountryFile);
|
||||
|
||||
[Fact]
|
||||
public void DxWorkingWvieScoresThree()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Equal(3, log.Judge(TestLog.Contact("K1ABC", exchange: "CT")).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DxWorkingDxScoresNothing()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Equal(0, log.Judge(TestLog.Contact("JA1XYZ", exchange: "5W")).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WvieWorkingWvieScoresNothing()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.UnitedStates);
|
||||
Assert.Equal(0, log.Judge(TestLog.Contact("VE3XYZ", exchange: "ON")).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DxCountsStatesAndProvinces()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Equal("CT", log.Judge(TestLog.Contact("K1ABC", exchange: "CT")).NewMultipliers.Single().Value);
|
||||
Assert.Empty(log.Judge(TestLog.Contact("K1ABC", exchange: "XX")).NewMultipliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WvieCountsCountries()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.UnitedStates);
|
||||
Assert.Equal("DL", log.Judge(TestLog.Contact("DL9XYZ", exchange: "100")).NewMultipliers.Single().Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipliersCountOncePerBand()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
log.Add(TestLog.Contact("K1ABC", exchange: "CT"));
|
||||
Assert.Empty(log.Judge(TestLog.Contact("K2ABC", exchange: "CT")).NewMultipliers);
|
||||
Assert.Single(log.Judge(TestLog.Contact("K2ABC", 7_025, exchange: "CT")).NewMultipliers);
|
||||
}
|
||||
}
|
||||
35
tests/Nonemm.Contests.Tests/SweepstakesTests.cs
Normal file
35
tests/Nonemm.Contests.Tests/SweepstakesTests.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Nonemm.Contests.Rules;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Tests;
|
||||
|
||||
public class SweepstakesTests
|
||||
{
|
||||
private static ContestLog Log() =>
|
||||
new(new Sweepstakes(ModeCategory.Cw), TestLog.UnitedStates, TestLog.CountryFile);
|
||||
|
||||
[Fact]
|
||||
public void EveryContactScoresTwo() =>
|
||||
Assert.Equal(2, Log().Judge(TestLog.Contact("K1ABC", section: "CT")).Points);
|
||||
|
||||
[Fact]
|
||||
public void AStationWorkedOnOneBandIsADupeOnEveryOther()
|
||||
{
|
||||
ContestLog log = Log();
|
||||
log.Add(TestLog.Contact("K1ABC", section: "CT"));
|
||||
Assert.True(log.Judge(TestLog.Contact("K1ABC", 7_025, section: "CT")).IsDupe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SectionsCountOnceForTheWholeContest()
|
||||
{
|
||||
ContestLog log = Log();
|
||||
log.Add(TestLog.Contact("K1ABC", section: "CT"));
|
||||
Assert.Empty(log.Judge(TestLog.Contact("K2ABC", 7_025, section: "CT")).NewMultipliers);
|
||||
Assert.Single(log.Judge(TestLog.Contact("K2ABC", 7_025, section: "WMA")).NewMultipliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SomethingThatIsNotASectionCountsForNothing() =>
|
||||
Assert.Empty(Log().Judge(TestLog.Contact("K1ABC", section: "ZZ")).NewMultipliers);
|
||||
}
|
||||
77
tests/Nonemm.Contests.Tests/UserDefinedContestTests.cs
Normal file
77
tests/Nonemm.Contests.Tests/UserDefinedContestTests.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Nonemm.Contests.Udc;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Tests;
|
||||
|
||||
public class UserDefinedContestTests
|
||||
{
|
||||
private const string Sample = """
|
||||
[Author]
|
||||
AuthorName=Someone
|
||||
[Contest]
|
||||
Name=SAMPLETEST
|
||||
DisplayName=Sample Test
|
||||
CabrilloName=SAMPLE-TEST
|
||||
Mode=CW
|
||||
DupeType=2
|
||||
Multiplier1Name=Countries
|
||||
MultSqlString=CountryPrefix
|
||||
IsMultPer=1
|
||||
PointsPerContact=MyCountry, 1, SameContinent, 2, OtherContinent, 5
|
||||
EntryWindowInfo=RCVText, 500, Exchange1Text, 500
|
||||
FrameText=RcvRST Exch
|
||||
DefaultContestExchange=599
|
||||
""";
|
||||
|
||||
private static UserDefinedContest Contest() => new(UdcFile.Parse(Sample));
|
||||
|
||||
private static ContestLog LogFor(StationInfo me) =>
|
||||
new(Contest(), me, TestLog.CountryFile);
|
||||
|
||||
[Fact]
|
||||
public void NameAndCabrilloNameComeFromTheFile()
|
||||
{
|
||||
UserDefinedContest contest = Contest();
|
||||
Assert.Equal("SAMPLETEST", contest.Name);
|
||||
Assert.Equal("Sample Test", contest.DisplayName);
|
||||
Assert.Equal("SAMPLE-TEST", contest.CabrilloName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExchangeBoxesComeFromTheEntryWindowSetting()
|
||||
{
|
||||
IReadOnlyList<ExchangeField> fields = Contest().ExchangeFieldsFor(TestLog.Germany);
|
||||
Assert.Equal(2, fields.Count);
|
||||
Assert.Equal("RcvRST", fields[0].Label);
|
||||
Assert.Equal(ExchangeSlot.Exchange1, fields[1].Slot);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DL9XYZ", 1)]
|
||||
[InlineData("IK2XYZ", 2)]
|
||||
[InlineData("JA1XYZ", 5)]
|
||||
public void PointsFollowTheConditionList(string call, int expected) =>
|
||||
Assert.Equal(expected, LogFor(TestLog.Germany).Judge(TestLog.Contact(call)).Points);
|
||||
|
||||
[Fact]
|
||||
public void CountriesCountOncePerBand()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
log.Add(TestLog.Contact("JA1XYZ"));
|
||||
Assert.Empty(log.Judge(TestLog.Contact("JA2XYZ")).NewMultipliers);
|
||||
Assert.Single(log.Judge(TestLog.Contact("JA2XYZ", 7_025)).NewMultipliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FileWithNoContestNameIsRejected() =>
|
||||
Assert.Throws<FormatException>(() => UdcFile.Parse("[Author]\r\nAuthorName=Someone\r\n"));
|
||||
|
||||
[Fact]
|
||||
public void DupeTypeFourNeverFlagsADupe()
|
||||
{
|
||||
UserDefinedContest contest = new(UdcFile.Parse(Sample.Replace("DupeType=2", "DupeType=4")));
|
||||
ContestLog log = new(contest, TestLog.Germany, TestLog.CountryFile);
|
||||
log.Add(TestLog.Contact("JA1XYZ"));
|
||||
Assert.False(log.Judge(TestLog.Contact("JA1XYZ")).IsDupe);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user