diff --git a/src/Nonemm.Contests/CabrilloExchange.cs b/src/Nonemm.Contests/CabrilloExchange.cs index f24a1a8..3200a70 100644 --- a/src/Nonemm.Contests/CabrilloExchange.cs +++ b/src/Nonemm.Contests/CabrilloExchange.cs @@ -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 Sent, IReadOnlyList Received); diff --git a/src/Nonemm.Contests/Contest.cs b/src/Nonemm.Contests/Contest.cs index a57cd32..d704a4c 100644 --- a/src/Nonemm.Contests/Contest.cs +++ b/src/Nonemm.Contests/Contest.cs @@ -14,7 +14,9 @@ public interface Contest /// The name the sponsor's Cabrillo header asks for. string CabrilloName { get; } - IReadOnlyList ExchangeFields { get; } + /// The boxes the entry window shows. ARRL DX and others ask for a + /// different exchange depending on where the operator is. + IReadOnlyList ExchangeFieldsFor(StationInfo me); /// Up to three names, in the order the score summary shows them. IReadOnlyList MultiplierNames { get; } diff --git a/src/Nonemm.Contests/ContestChoice.cs b/src/Nonemm.Contests/ContestChoice.cs new file mode 100644 index 0000000..3be13a0 --- /dev/null +++ b/src/Nonemm.Contests/ContestChoice.cs @@ -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 Modes, + Func Create); diff --git a/src/Nonemm.Contests/ContestLog.cs b/src/Nonemm.Contests/ContestLog.cs index 5a1135a..3d68285 100644 --- a/src/Nonemm.Contests/ContestLog.cs +++ b/src/Nonemm.Contests/ContestLog.cs @@ -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 WorkedBefore(string call) => diff --git a/src/Nonemm.Contests/ContestRegistry.cs b/src/Nonemm.Contests/ContestRegistry.cs new file mode 100644 index 0000000..103867d --- /dev/null +++ b/src/Nonemm.Contests/ContestRegistry.cs @@ -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 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 byName; + + private ContestRegistry(Dictionary byName) => this.byName = byName; + + public static ContestRegistry Create(IEnumerable? userDefined = null) + { + Dictionary 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 problems) + { + List files = []; + List 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 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); +} diff --git a/src/Nonemm.Contests/DupeScope.cs b/src/Nonemm.Contests/DupeScope.cs index 9db2334..b5dc57a 100644 --- a/src/Nonemm.Contests/DupeScope.cs +++ b/src/Nonemm.Contests/DupeScope.cs @@ -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, } diff --git a/src/Nonemm.Contests/Multipliers/ArrlSections.cs b/src/Nonemm.Contests/Multipliers/ArrlSections.cs new file mode 100644 index 0000000..31136cd --- /dev/null +++ b/src/Nonemm.Contests/Multipliers/ArrlSections.cs @@ -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 All = new HashSet(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()); +} diff --git a/src/Nonemm.Contests/Multipliers/StatesAndProvinces.cs b/src/Nonemm.Contests/Multipliers/StatesAndProvinces.cs new file mode 100644 index 0000000..07d794a --- /dev/null +++ b/src/Nonemm.Contests/Multipliers/StatesAndProvinces.cs @@ -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 ContiguousStates = new HashSet(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 AllStates = + new HashSet(ContiguousStates.Concat(["AK", "HI"]), StringComparer.OrdinalIgnoreCase); + + /// The 13 Canadian provinces and territories. + public static readonly IReadOnlySet Provinces = new HashSet(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 ArrlDxMultipliers = + new HashSet(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); +} diff --git a/src/Nonemm.Contests/Rules/ArrlDx.cs b/src/Nonemm.Contests/Rules/ArrlDx.cs new file mode 100644 index 0000000..027ecdb --- /dev/null +++ b/src/Nonemm.Contests/Rules/ArrlDx.cs @@ -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 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 MultiplierNames => ["Mults"]; + + public DupeScope DupeScope => DupeScope.PerBand; + + public bool HasSerialNumbers => false; + + public IReadOnlyList 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 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"; +} diff --git a/src/Nonemm.Contests/Rules/CqWorldWide.cs b/src/Nonemm.Contests/Rules/CqWorldWide.cs index c949a76..606d929 100644 --- a/src/Nonemm.Contests/Rules/CqWorldWide.cs +++ b/src/Nonemm.Contests/Rules/CqWorldWide.cs @@ -17,7 +17,7 @@ public sealed class CqWorldWide : Contest public string CabrilloName => mode == ModeCategory.Cw ? "CQ-WW-CW" : "CQ-WW-SSB"; - public IReadOnlyList ExchangeFields => + public IReadOnlyList 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"; diff --git a/src/Nonemm.Contests/Rules/CqWpx.cs b/src/Nonemm.Contests/Rules/CqWpx.cs index 615e11b..8797dd3 100644 --- a/src/Nonemm.Contests/Rules/CqWpx.cs +++ b/src/Nonemm.Contests/Rules/CqWpx.cs @@ -26,7 +26,7 @@ public sealed class CqWpx : Contest _ => "CQ-WPX-RTTY", }; - public IReadOnlyList ExchangeFields => + public IReadOnlyList 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 { diff --git a/src/Nonemm.Contests/Rules/GeneralLogging.cs b/src/Nonemm.Contests/Rules/GeneralLogging.cs new file mode 100644 index 0000000..494b70c --- /dev/null +++ b/src/Nonemm.Contests/Rules/GeneralLogging.cs @@ -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 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 MultiplierNames => []; + + public DupeScope DupeScope => DupeScope.PerBandAndMode; + + public bool HasSerialNumbers => false; + + public IReadOnlyList Modes => []; + + public string SentExchangeFor(StationInfo me) => "599"; + + public int PointsFor(QsoContext qso) => 0; + + public IReadOnlyList 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)]); +} diff --git a/src/Nonemm.Contests/Rules/IaruHf.cs b/src/Nonemm.Contests/Rules/IaruHf.cs new file mode 100644 index 0000000..5664b76 --- /dev/null +++ b/src/Nonemm.Contests/Rules/IaruHf.cs @@ -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 ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Zone/HQ", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 }, + ]; + + public IReadOnlyList MultiplierNames => ["Zones", "HQ"]; + + public DupeScope DupeScope => DupeScope.PerBandAndMode; + + public bool HasSerialNumbers => false; + + public IReadOnlyList 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 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); +} diff --git a/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs b/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs new file mode 100644 index 0000000..9a2c191 --- /dev/null +++ b/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs @@ -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 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 MultiplierNames => ["Mults"]; + + public DupeScope DupeScope => DupeScope.PerBand; + + public bool HasSerialNumbers => false; + + public IReadOnlyList 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 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", + }; +} diff --git a/src/Nonemm.Contests/Rules/RttyRoundup.cs b/src/Nonemm.Contests/Rules/RttyRoundup.cs new file mode 100644 index 0000000..ddd3ffd --- /dev/null +++ b/src/Nonemm.Contests/Rules/RttyRoundup.cs @@ -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 ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("S/P/Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 }, + ]; + + public IReadOnlyList MultiplierNames => ["Mults"]; + + public DupeScope DupeScope => DupeScope.PerBand; + + public bool HasSerialNumbers => true; + + public IReadOnlyList 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 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), + ]); +} diff --git a/src/Nonemm.Contests/Rules/Sweepstakes.cs b/src/Nonemm.Contests/Rules/Sweepstakes.cs new file mode 100644 index 0000000..f4ba39f --- /dev/null +++ b/src/Nonemm.Contests/Rules/Sweepstakes.cs @@ -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 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 MultiplierNames => ["Sections"]; + + public DupeScope DupeScope => DupeScope.Once; + + public bool HasSerialNumbers => true; + + public IReadOnlyList Modes => [mode]; + + public string SentExchangeFor(StationInfo me) => + $"{me.Precedence} {me.Callsign} {me.Check:00} {me.ArrlSection}"; + + public int PointsFor(QsoContext qso) => 2; + + public IReadOnlyList 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"; +} diff --git a/src/Nonemm.Contests/Udc/UdcFile.cs b/src/Nonemm.Contests/Udc/UdcFile.cs new file mode 100644 index 0000000..7422fdb --- /dev/null +++ b/src/Nonemm.Contests/Udc/UdcFile.cs @@ -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 values; + + private UdcFile(Dictionary values) => this.values = values; + + public static UdcFile Parse(string text) + { + Dictionary 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 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; +} diff --git a/src/Nonemm.Contests/Udc/UdcMultiplierScope.cs b/src/Nonemm.Contests/Udc/UdcMultiplierScope.cs new file mode 100644 index 0000000..d54534f --- /dev/null +++ b/src/Nonemm.Contests/Udc/UdcMultiplierScope.cs @@ -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, +} diff --git a/src/Nonemm.Contests/Udc/UdcMultipliers.cs b/src/Nonemm.Contests/Udc/UdcMultipliers.cs new file mode 100644 index 0000000..864e61b --- /dev/null +++ b/src/Nonemm.Contests/Udc/UdcMultipliers.cs @@ -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 onlyForCountries; + + public UdcMultiplier( + int index, + string source, + UdcMultiplierScope scope, + IReadOnlyList 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]; + } +} diff --git a/src/Nonemm.Contests/Udc/UdcPoints.cs b/src/Nonemm.Contests/Udc/UdcPoints.cs new file mode 100644 index 0000000..e294cf1 --- /dev/null +++ b/src/Nonemm.Contests/Udc/UdcPoints.cs @@ -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[] 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(); +} diff --git a/src/Nonemm.Contests/Udc/UserDefinedContest.cs b/src/Nonemm.Contests/Udc/UserDefinedContest.cs new file mode 100644 index 0000000..c6fcb87 --- /dev/null +++ b/src/Nonemm.Contests/Udc/UserDefinedContest.cs @@ -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 multipliers; + private readonly IReadOnlyList 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 ExchangeFieldsFor(StationInfo me) => exchangeFields; + + public IReadOnlyList 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 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 MultipliersFor(QsoContext qso) => + multipliers.Select(m => m.For(qso)).OfType().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 ReadMultipliers(UdcFile file) + { + UdcMultiplierScope shared = (UdcMultiplierScope)file.Number("IsMultPer", 0); + List 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 ReadExchangeFields(UdcFile file) + { + string[] labels = file.Text("FrameText").Split(' ', StringSplitOptions.RemoveEmptyEntries); + List 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, + }; +} diff --git a/src/Nonemm.Core/StationInfo.cs b/src/Nonemm.Core/StationInfo.cs index 29c3be2..5f0e864 100644 --- a/src/Nonemm.Core/StationInfo.cs +++ b/src/Nonemm.Core/StationInfo.cs @@ -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; } diff --git a/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs b/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs index 9a2cdfa..a43d917 100644 --- a/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs +++ b/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs @@ -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(); diff --git a/tests/Nonemm.Contests.Tests/ArrlDxTests.cs b/tests/Nonemm.Contests.Tests/ArrlDxTests.cs new file mode 100644 index 0000000..75e89b0 --- /dev/null +++ b/tests/Nonemm.Contests.Tests/ArrlDxTests.cs @@ -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); + } +} diff --git a/tests/Nonemm.Contests.Tests/SweepstakesTests.cs b/tests/Nonemm.Contests.Tests/SweepstakesTests.cs new file mode 100644 index 0000000..0611f5f --- /dev/null +++ b/tests/Nonemm.Contests.Tests/SweepstakesTests.cs @@ -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); +} diff --git a/tests/Nonemm.Contests.Tests/UserDefinedContestTests.cs b/tests/Nonemm.Contests.Tests/UserDefinedContestTests.cs new file mode 100644 index 0000000..738c333 --- /dev/null +++ b/tests/Nonemm.Contests.Tests/UserDefinedContestTests.cs @@ -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 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(() => 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); + } +}