From 04568d0ca353015223760fb694d8d2e2a7290346 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Mon, 31 Aug 2026 08:33:40 +0000 Subject: [PATCH] Read the rest of what a .udc file says, and score what N1MM scores Two pieces of work on the scoring engine. The .udc reader now takes N1MM's published scoring vocabulary: the full PointsPerContact condition set, the PointsMultBy family, PowerMult, BonusPoints, the multiplier sources and the settings that say which stations bring a multiplier in. MultMult is a weight rather than a switch, as in N1MM's ComputeScore. The entry categories and the sent exchange reach the rules in a new ContestEntry, so a contest can score by the power category it is entered in or by what this station sends. Then every contest in a real N1MM log was scored again from these rules and compared with the points and multiplier flags N1MM wrote. That found five bugs: ARRL DX and IARU read the exchange from the wrong column, a stored " " was read as a value rather than as empty, CQ WW RTTY's own Canadian area names were not counted, a maritime mobile scored nothing, and a contact in a mode the contest does not run scored as if it were in the contest. docs/n1mm-interop.md has the check and what still differs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD --- docs/n1mm-interop.md | 47 ++++ docs/unfinished.md | 24 +- src/Nonemm.Contests/Contest.cs | 9 +- src/Nonemm.Contests/ContestLog.cs | 49 +++- .../Multipliers/StatesAndProvinces.cs | 43 ++- src/Nonemm.Contests/QsoContext.cs | 16 +- src/Nonemm.Contests/Rules/ArrlDx.cs | 13 +- src/Nonemm.Contests/Rules/CqWorldWide.cs | 16 +- src/Nonemm.Contests/Rules/CqWpx.cs | 2 +- src/Nonemm.Contests/Rules/GeneralLogging.cs | 2 +- src/Nonemm.Contests/Rules/IaruHf.cs | 11 +- .../Rules/NorthAmericanQsoParty.cs | 2 +- src/Nonemm.Contests/Rules/RttyRoundup.cs | 2 +- src/Nonemm.Contests/Rules/Sweepstakes.cs | 2 +- src/Nonemm.Contests/Rules/Wae.cs | 2 +- src/Nonemm.Contests/Udc/UdcCondition.cs | 114 ++++++++ src/Nonemm.Contests/Udc/UdcMultipliers.cs | 112 ++++++-- src/Nonemm.Contests/Udc/UdcPoints.cs | 124 ++++---- .../Udc/UdcPointsMultiplier.cs | 150 ++++++++++ src/Nonemm.Contests/Udc/UserDefinedContest.cs | 105 +++++-- src/Nonemm.Core/ContestEntry.cs | 34 +++ src/Nonemm.Session/ContestSession.cs | 2 +- src/Nonemm.Storage/ContestInstance.cs | 17 ++ src/Nonemm.Storage/QsoColumns.cs | 4 +- tests/Nonemm.Contests.Tests/ArrlDxTests.cs | 32 ++- .../CqWorldWideRttyTests.cs | 20 ++ tests/Nonemm.Contests.Tests/CqWpxTests.cs | 12 + .../Nonemm.Contests.Tests/UdcScoringTests.cs | 264 ++++++++++++++++++ 28 files changed, 1088 insertions(+), 142 deletions(-) create mode 100644 src/Nonemm.Contests/Udc/UdcCondition.cs create mode 100644 src/Nonemm.Contests/Udc/UdcPointsMultiplier.cs create mode 100644 src/Nonemm.Core/ContestEntry.cs create mode 100644 tests/Nonemm.Contests.Tests/UdcScoringTests.cs diff --git a/docs/n1mm-interop.md b/docs/n1mm-interop.md index 9c47296..cd6700b 100644 --- a/docs/n1mm-interop.md +++ b/docs/n1mm-interop.md @@ -79,3 +79,50 @@ opened. The name carries the mode, so it beats whatever `ModeCategory` says. Older N1MM versions wrote the CW running of CQ WW as plain `CQWW`. That name is still in the registry, so those logs open. + +## Which column an exchange lands in + +N1MM keeps the received exchange of ARRL DX, IARU and WAE in `Sect`, not in +`Exchange1`, whatever the entry window calls the box. A contest that reads the +wrong column scores every contact as if the operator had typed nothing, so the +rule reads the column N1MM writes and the entry window points its box at the +same place. + +N1MM also writes a single space into a text column it has nothing for, so +`Continent` comes back as `" "` rather than empty. Reading it as a value rather +than as empty makes every contact look like another continent. The store trims +what it reads. + +## Scoring checked against a real log + +`om5m.s3db` in the repo root is a station's own N1MM log: 78 contest instances +from 2019 to 2026, 57,000 contacts, 28 contest names. Replaying it is how the +scoring rules are checked — every contact is scored again from our rules and +compared with the points and multiplier flags N1MM wrote. + +26 of the 37 instances whose contest we have rules for now agree on every +contact and on the totals. What the check found and fixed: + +| What | Was | +|---|---| +| ARRL DX and IARU read the wrong column | no multipliers at all, and IARU scored every contact 1 point | +| a stored `" "` was read as a value | 181 contacts in one log scored as another continent | +| the CQ WW RTTY area list | `NF` and `LB` are that contest's own names for the Canadian areas, and were not counted | +| ARRL DX province spellings | `QB`, `NF` and `LB` count as Quebec and as Newfoundland and Labrador | +| a maritime mobile scored nothing | it counts for no country and still scores by continent | +| a contact in a mode the contest does not run | scored as if it were in the contest; N1MM scores it zero and keeps its multiplier | + +What still differs, and why: + +- **The country file of the day.** A 2019 log holds calls that today's + `wl_cty.dat` places elsewhere, and the multiplier moves to another contact + with it. This is most of the difference in the 2019 CQ WW SSB log. +- **N1MM counts a call it cannot place as a multiplier with an empty value**, + and we do not. Same as the WAE difference already written down. +- **N1MM counts `NF` and `LB` as two ARRL DX multipliers.** They are one + province, and we count one. +- **A maritime mobile brings no country multiplier here**, which is the + published CQ WW rule; N1MM counts one for it. +- **Stale cached points.** N1MM caches points in the row and does not always + work them out again after a callsign is corrected, so a handful of rows hold + a number that its own rules do not produce. diff --git a/docs/unfinished.md b/docs/unfinished.md index 0e4ed17..da768fc 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -1,7 +1,7 @@ # What is not finished, and what has not been tested Two separate lists. A feature can be finished and untested, or half-built and -exercised every day. Written 2026-08-27. +exercised every day. Written 2026-08-27, updated 2026-08-31. ## Never run against the real thing @@ -107,10 +107,24 @@ needs QTC traffic, IOTA needs island references as multipliers, and CQ WW RTTY needed a third multiplier and its own points table because it shares a name with the CW and SSB running of the contest. -What `.udc` cannot say here yet: an exchange that differs by the other -station's country (the OK/OM district against a serial number from everyone -else), points that depend on a zone or a distance, and multiplier sources -beyond country, prefix, zone, section, exchange, grid and continent. +`.udc` settings that are still passed over. `CallHist` as a multiplier source, +which takes the value from the call history file. `BonusPoints2`, which reads +the bonus callsigns from a file. `MultiplierBands`, `MultWindowType` and +`QsoErrorString`, which are about the windows rather than the score. +`CabrilloString`, `CabrilloFormat` and `GenericPrintString`, so a contest whose +Cabrillo line is not callsign, report and one exchange value comes out in the +default shape. The session, off-time and band-change settings +(`MultipleSessions`, `MinimumOffTime`, the `…BandChange…` family, +`DupeQSOMinutesAgo`) are read by nothing, so a contest with periods is logged +as one long session. + +N1MM's `.udc` format has no way to say that the exchange itself differs by the +other station's country, as the OK/OM DX contest asks a district from OK and OM +stations and a serial number from everyone else. That one needs code, as it +does in N1MM, and `Contest.SkipsField` is where it goes. A contest scored by +what this station sends — YOTA counts the sent exchange — needs code too, and +reads `QsoContext.Entry`, which carries the entry categories and the sent +exchange. ## Known rough edges diff --git a/src/Nonemm.Contests/Contest.cs b/src/Nonemm.Contests/Contest.cs index 2b7aa5c..7eb42ca 100644 --- a/src/Nonemm.Contests/Contest.cs +++ b/src/Nonemm.Contests/Contest.cs @@ -30,6 +30,11 @@ public interface Contest DupeScope DupeScope { get; } + /// One more field that makes a contact distinct, on top of the call, band + /// and mode the dupe scope names. Empty for every contest that dupes on the + /// callsign alone. + string ExtraDupeKey(Qso qso) => ""; + /// True when serial numbers count up across the whole contest rather than /// per band. bool HasSerialNumbers { get; } @@ -61,7 +66,9 @@ public interface Contest IReadOnlyList MultipliersFor(QsoContext qso); - int TotalScore(ScoreTally tally); + /// The claimed score. The entry is here because a contest can multiply + /// the whole score by the power category the operator entered in. + int TotalScore(ScoreTally tally, ContestEntry entry); /// The exchange columns of this contact's Cabrillo line. CabrilloExchange CabrilloExchange(Qso qso, StationInfo me); diff --git a/src/Nonemm.Contests/ContestLog.cs b/src/Nonemm.Contests/ContestLog.cs index ee85164..986c23d 100644 --- a/src/Nonemm.Contests/ContestLog.cs +++ b/src/Nonemm.Contests/ContestLog.cs @@ -15,20 +15,29 @@ public sealed class ContestLog private readonly HashSet claimedMultipliers = new(StringComparer.Ordinal); private ScoreTally tally = new(); - public ContestLog(Contest contest, StationInfo me, CountryFile? countries) + public ContestLog( + Contest contest, + StationInfo me, + CountryFile? countries, + ContestEntry? entry = null) { this.contest = contest; this.me = me; this.countries = countries; + Entry = entry ?? new ContestEntry(); } + /// The categories and sent exchange of the running entry. The operator can + /// change them while the contest is open, and the score follows. + public ContestEntry Entry { get; set; } + public Contest Contest => contest; public IReadOnlyList Qsos => qsos; public ScoreTally Tally => tally; - public int TotalScore => contest.TotalScore(tally); + public int TotalScore => contest.TotalScore(tally, Entry); /// What logging this contact right now would do, without logging it. public Verdict Judge(Qso candidate) @@ -50,7 +59,10 @@ public sealed class ContestLog newOnes.Add(multiplier); } } - return new Verdict(false, contest.PointsFor(context), newOnes); + // a contest can pay a bonus for a new multiplier, so the flags have to + // be set before the points are worked out + QsoContext scored = ContextFor(ApplyMultiplierFlags(candidate, newOnes)); + return new Verdict(false, IsContestMode(candidate) ? contest.PointsFor(scored) : 0, newOnes); } /// Adds the contact and returns it with points and multiplier flags filled in. @@ -108,15 +120,28 @@ public sealed class ContestLog .OrderByDescending(q => q.TimestampUtc) .ToList(); - private QsoContext ContextFor(Qso qso) => - new(qso, countries?.Find(qso.Call), me); + /// A contact in a mode the contest does not run — a tuning carrier logged + /// by accident, or a CW contact in an SSB contest — scores nothing. It + /// still brings its multiplier, which is what N1MM logs. A contest with no + /// mode of its own takes every mode. + private bool IsContestMode(Qso qso) => + contest.Modes.Count == 0 || contest.Modes.Contains(qso.Mode.Category); - private Qso ApplyVerdict(Qso qso, Verdict verdict) => qso with + /// A maritime or aeronautical mobile belongs to no country, and the + /// contest still has to know which continent it is working, so the call it + /// is signing from is looked up when the call as sent places nowhere. What + /// that country counts for is the contest's decision. + private QsoContext ContextFor(Qso qso) => + new(qso, countries?.Find(qso.Call) ?? countries?.Find(qso.Call.Station), me, Entry); + + private static Qso ApplyVerdict(Qso qso, Verdict verdict) => + ApplyMultiplierFlags(qso, verdict.NewMultipliers) with { Points = verdict.Points }; + + private static Qso ApplyMultiplierFlags(Qso qso, IReadOnlyList claimed) => qso with { - Points = verdict.Points, - IsMultiplier1 = verdict.NewMultipliers.Any(m => m.Index == 1), - IsMultiplier2 = verdict.NewMultipliers.Any(m => m.Index == 2), - IsMultiplier3 = verdict.NewMultipliers.Any(m => m.Index == 3), + IsMultiplier1 = claimed.Any(m => m.Index == 1), + IsMultiplier2 = claimed.Any(m => m.Index == 2), + IsMultiplier3 = claimed.Any(m => m.Index == 3), }; private void Index(Qso qso, Verdict verdict) @@ -155,7 +180,8 @@ public sealed class ContestLog private string DupeKey(Qso qso) { string call = qso.Call.Text.ToUpperInvariant(); - return contest.DupeScope switch + string extra = contest.ExtraDupeKey(qso); + string key = contest.DupeScope switch { DupeScope.Once => call, DupeScope.PerBand => $"{call}|{qso.Band?.Name}", @@ -163,6 +189,7 @@ public sealed class ContestLog DupeScope.PerBandAndMode => $"{call}|{qso.Band?.Name}|{qso.Mode.Category}", _ => call, }; + return extra.Length == 0 ? key : $"{key}|{extra}"; } private static string MultiplierKey(Multiplier multiplier) => diff --git a/src/Nonemm.Contests/Multipliers/StatesAndProvinces.cs b/src/Nonemm.Contests/Multipliers/StatesAndProvinces.cs index 07d794a..b58fe86 100644 --- a/src/Nonemm.Contests/Multipliers/StatesAndProvinces.cs +++ b/src/Nonemm.Contests/Multipliers/StatesAndProvinces.cs @@ -21,11 +21,50 @@ public static class StatesAndProvinces "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. + /// The Canadian areas as the contests that split them name them: + /// Newfoundland apart from Labrador, and the territories spelled out. + public static readonly IReadOnlyList SplitCanadianAreas = + ["NWT", "NF", "LB", "PEI"]; + + /// 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); + /// The same province under another name. Canadian stations send the older + /// abbreviations as often as the postal codes, and ARRL DX counts the + /// province, not the spelling, so `NF` and `LB` are both Newfoundland and + /// Labrador and count once between them. N1MM counts them as two. + private static readonly IReadOnlyDictionary OtherSpellings = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["NF"] = "NL", + ["LB"] = "NL", + ["NFL"] = "NL", + ["QB"] = "QC", + ["PQ"] = "QC", + ["PEI"] = "PE", + ["NWT"] = "NT", + }; + + /// The province or state a received ARRL DX exchange names, or the exchange + /// as it was sent when it names none. + public static string ArrlDxArea(string received) + { + string text = received.Trim(); + return OtherSpellings.TryGetValue(text, out string? province) ? province : text.ToUpperInvariant(); + } + + /// What CQ WW RTTY counts: the 48 contiguous states, DC, and the Canadian + /// areas as that contest names them, which is not the postal list — it + /// splits Newfoundland from Labrador and spells out `NWT` and `PEI`. + public static readonly IReadOnlySet CqWwRttyAreas = + new HashSet( + ContiguousStates + .Concat(["DC", "NB", "NS", "QC", "ON", "MB", "SK", "AB", "BC", "NU", "YT"]) + .Concat(SplitCanadianAreas), + 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/QsoContext.cs b/src/Nonemm.Contests/QsoContext.cs index 2054223..6d20e6a 100644 --- a/src/Nonemm.Contests/QsoContext.cs +++ b/src/Nonemm.Contests/QsoContext.cs @@ -5,7 +5,7 @@ namespace Nonemm.Contests; /// A QSO together with what the country file says about it, so a contest can /// score by country, zone or continent without looking anything up itself. -public sealed record QsoContext(Qso Qso, CountryLookup? Country, StationInfo Me) +public sealed record QsoContext(Qso Qso, CountryLookup? Country, StationInfo Me, ContestEntry Entry) { public Band? Band => Qso.Band; @@ -15,10 +15,12 @@ public sealed record QsoContext(Qso Qso, CountryLookup? Country, StationInfo Me) /// the operator corrected the country by hand, and then the correction is /// the point. public string Continent => - Qso.Continent.Length > 0 ? Qso.Continent : Country?.Continent ?? ""; + Qso.Continent.Trim().Length > 0 ? Qso.Continent.Trim() : Country?.Continent ?? ""; public string CountryPrefix => - Qso.CountryPrefix.Length > 0 ? Qso.CountryPrefix : Country?.Entity.PrimaryPrefix ?? ""; + Qso.CountryPrefix.Trim().Length > 0 + ? Qso.CountryPrefix.Trim() + : Country?.Entity.PrimaryPrefix ?? ""; public int CqZone => Country?.CqZone ?? Qso.Zone; @@ -26,5 +28,13 @@ public sealed record QsoContext(Qso Qso, CountryLookup? Country, StationInfo Me) public bool IsSameCountry => CountryPrefix.Length > 0 && CountryPrefix == Me.CountryPrefix; + /// Great-circle kilometres between the two grid squares, and zero when + /// either station has no locator. + public double DistanceKm => + GridSquare.TryParse(Qso.GridSquare, out GridSquare theirs) + && GridSquare.TryParse(Me.GridSquare, out GridSquare mine) + ? mine.DistanceTo(theirs) + : 0.0; + public bool IsSameContinent => Continent.Length > 0 && Continent == Me.Continent; } diff --git a/src/Nonemm.Contests/Rules/ArrlDx.cs b/src/Nonemm.Contests/Rules/ArrlDx.cs index 6396759..49fc1dd 100644 --- a/src/Nonemm.Contests/Rules/ArrlDx.cs +++ b/src/Nonemm.Contests/Rules/ArrlDx.cs @@ -6,6 +6,9 @@ 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. +/// +/// The received power or state goes in the section column, which is where N1MM +/// keeps it, so a log written by either program scores the same in the other. public sealed class ArrlDx : Contest { private readonly ModeCategory mode; @@ -22,8 +25,8 @@ public sealed class ArrlDx : Contest [ new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), IsNorthAmericanHome(me) - ? new ExchangeField("Pwr", ExchangeSlot.Exchange1, ExchangeFieldKind.Power) - : new ExchangeField("S/P", ExchangeSlot.Exchange1, ExchangeFieldKind.UsStateOrCanadianProvince), + ? new ExchangeField("Pwr", ExchangeSlot.Section, ExchangeFieldKind.Power) + : new ExchangeField("S/P", ExchangeSlot.Section, ExchangeFieldKind.UsStateOrCanadianProvince), ]; public IReadOnlyList MultiplierNames => ["Mults"]; @@ -53,13 +56,13 @@ public sealed class ArrlDx : Contest { return qso.CountryPrefix.Length == 0 ? [] : [new Multiplier(1, qso.CountryPrefix, band)]; } - string received = qso.Qso.Exchange1.Trim().ToUpperInvariant(); + string received = StatesAndProvinces.ArrlDxArea(qso.Qso.Section); return StatesAndProvinces.ArrlDxMultipliers.Contains(received) ? [new Multiplier(1, received, band)] : []; } - public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers; + public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers; public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => new( @@ -71,7 +74,7 @@ public sealed class ArrlDx : Contest [ new CabrilloField(qso.Call.Text, 13), new CabrilloField(qso.ReceivedReport, 3), - new CabrilloField(qso.Exchange1, 6), + new CabrilloField(qso.Section, 6), ]); private static bool IsNorthAmericanHome(StationInfo me) => IsNorthAmerican(me.CountryPrefix); diff --git a/src/Nonemm.Contests/Rules/CqWorldWide.cs b/src/Nonemm.Contests/Rules/CqWorldWide.cs index 427297e..c6c6886 100644 --- a/src/Nonemm.Contests/Rules/CqWorldWide.cs +++ b/src/Nonemm.Contests/Rules/CqWorldWide.cs @@ -73,17 +73,17 @@ public sealed class CqWorldWide : Contest public string SentExchangeFor(StationInfo me) => IsRtty ? $"{me.CqZone} {HomeOf(me)}" : me.CqZone.ToString(); + /// A maritime mobile station counts for no country, and still scores by + /// the continent it is operating from, which is what N1MM logs. public int PointsFor(QsoContext qso) { - if (!qso.Qso.Call.CountsForEntity) - { - return 0; - } if (IsRtty) { - return qso.IsSameCountry ? 1 : qso.IsSameContinent ? 2 : 3; + return qso.IsSameCountry && qso.Qso.Call.CountsForEntity ? 1 + : qso.IsSameContinent ? 2 + : 3; } - if (qso.IsSameCountry) + if (qso.IsSameCountry && qso.Qso.Call.CountsForEntity) { return 0; } @@ -107,14 +107,14 @@ public sealed class CqWorldWide : Contest found.Add(new Multiplier(2, qso.CountryPrefix, band)); } string home = qso.Qso.Section.Trim().ToUpperInvariant(); - if (IsRtty && StatesAndProvinces.IsStateOrProvince(home)) + if (IsRtty && StatesAndProvinces.CqWwRttyAreas.Contains(home)) { found.Add(new Multiplier(3, home, band)); } return found; } - public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers; + public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers; public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => IsRtty ? new CabrilloExchange( diff --git a/src/Nonemm.Contests/Rules/CqWpx.cs b/src/Nonemm.Contests/Rules/CqWpx.cs index 0d6a38d..eb73a66 100644 --- a/src/Nonemm.Contests/Rules/CqWpx.cs +++ b/src/Nonemm.Contests/Rules/CqWpx.cs @@ -74,7 +74,7 @@ public sealed class CqWpx : Contest return prefix is null ? [] : [new Multiplier(1, prefix, "")]; } - public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers; + public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers; public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => new( diff --git a/src/Nonemm.Contests/Rules/GeneralLogging.cs b/src/Nonemm.Contests/Rules/GeneralLogging.cs index 60184f4..572a40e 100644 --- a/src/Nonemm.Contests/Rules/GeneralLogging.cs +++ b/src/Nonemm.Contests/Rules/GeneralLogging.cs @@ -35,7 +35,7 @@ public sealed class GeneralLogging : Contest public IReadOnlyList MultipliersFor(QsoContext qso) => []; - public int TotalScore(ScoreTally tally) => tally.Qsos; + public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Qsos; public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => new( diff --git a/src/Nonemm.Contests/Rules/IaruHf.cs b/src/Nonemm.Contests/Rules/IaruHf.cs index a366435..be99a74 100644 --- a/src/Nonemm.Contests/Rules/IaruHf.cs +++ b/src/Nonemm.Contests/Rules/IaruHf.cs @@ -4,6 +4,9 @@ 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. +/// +/// The received zone or abbreviation goes in the section column, which is where +/// N1MM keeps it. public sealed class IaruHf : Contest { public string Name => "IARU"; @@ -15,7 +18,7 @@ public sealed class IaruHf : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("Zone/HQ", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 }, + new ExchangeField("Zone/HQ", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 6 }, ]; public IReadOnlyList MultiplierNames => ["Zones", "HQ"]; @@ -55,7 +58,7 @@ public sealed class IaruHf : Contest return [new Multiplier(IsZone(exchange) ? 1 : 2, exchange, band)]; } - public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers; + public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers; public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => new( @@ -67,10 +70,10 @@ public sealed class IaruHf : Contest [ new CabrilloField(qso.Call.Text, 13), new CabrilloField(qso.ReceivedReport, 3), - new CabrilloField(qso.Exchange1, 6), + new CabrilloField(qso.Section, 6), ]); - private static string Exchange(QsoContext qso) => qso.Qso.Exchange1.Trim().ToUpperInvariant(); + private static string Exchange(QsoContext qso) => qso.Qso.Section.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 index 9a2c191..5874906 100644 --- a/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs +++ b/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs @@ -54,7 +54,7 @@ public sealed class NorthAmericanQsoParty : Contest : []; } - public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers; + public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers; public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => new( diff --git a/src/Nonemm.Contests/Rules/RttyRoundup.cs b/src/Nonemm.Contests/Rules/RttyRoundup.cs index a2771f0..15bffbb 100644 --- a/src/Nonemm.Contests/Rules/RttyRoundup.cs +++ b/src/Nonemm.Contests/Rules/RttyRoundup.cs @@ -44,7 +44,7 @@ public sealed class RttyRoundup : Contest return qso.CountryPrefix.Length == 0 ? [] : [new Multiplier(1, qso.CountryPrefix, "")]; } - public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers; + public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers; public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => new( diff --git a/src/Nonemm.Contests/Rules/Sweepstakes.cs b/src/Nonemm.Contests/Rules/Sweepstakes.cs index f854e9f..3154506 100644 --- a/src/Nonemm.Contests/Rules/Sweepstakes.cs +++ b/src/Nonemm.Contests/Rules/Sweepstakes.cs @@ -44,7 +44,7 @@ public sealed class Sweepstakes : Contest return ArrlSections.IsSection(section) ? [new Multiplier(1, section, "")] : []; } - public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers; + public int TotalScore(ScoreTally tally, ContestEntry entry) => 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. diff --git a/src/Nonemm.Contests/Rules/Wae.cs b/src/Nonemm.Contests/Rules/Wae.cs index e964dc2..7f8e093 100644 --- a/src/Nonemm.Contests/Rules/Wae.cs +++ b/src/Nonemm.Contests/Rules/Wae.cs @@ -94,7 +94,7 @@ public sealed class Wae : Contest : [new Multiplier(1, value, qso.Band?.Name ?? "", weight)]; } - public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers; + public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers; /// A QTC is written as its own Cabrillo record: the station that received /// the traffic, the series, the station that sent it, and then the contact diff --git a/src/Nonemm.Contests/Udc/UdcCondition.cs b/src/Nonemm.Contests/Udc/UdcCondition.cs new file mode 100644 index 0000000..6b75620 --- /dev/null +++ b/src/Nonemm.Contests/Udc/UdcCondition.cs @@ -0,0 +1,114 @@ +using Nonemm.Core; + +namespace Nonemm.Contests.Udc; + +/// One condition from a `.udc` scoring list, tested against a contact. +/// +/// N1MM's published keywords, plus three prefixed forms: `SectIs_x`, `ExchIs_x` +/// and `MiscIs_x` compare the seventh character onwards with the received +/// section, exchange or misc box. A condition that is none of the keywords is +/// taken as a country prefix or a whole callsign. +public static class UdcCondition +{ + public static bool Matches(QsoContext qso, string condition, string mySentExchange) + { + string test = condition.Trim().ToUpperInvariant(); + return test switch + { + "" => false, + "MYCOUNTRY" => qso.IsSameCountry, + "MYEXCHANGE" => Same(qso.Qso.Section, mySentExchange), + "MYGRID" => SameGridField(qso.Qso.GridSquare, qso.Me.GridSquare), + "MYCQZONE" => qso.CqZone > 0 && qso.CqZone == qso.Me.CqZone, + "MYIARUZONE" => qso.ItuZone > 0 && qso.ItuZone == qso.Me.ItuZone, + "SAMECONTINENT" => qso.IsSameContinent, + "OTHERCONTINENT" => qso.Continent.Length > 0 && !qso.IsSameContinent, + "ISEXCH" => qso.Qso.Exchange1.Trim().Length > 0, + "ISMISC" => qso.Qso.MiscText.Trim().Length > 0, + "ISCOMMENT" => qso.Qso.Comment.Trim().Length > 0, + "ISMULT1" => qso.Qso.IsMultiplier1, + "ISMULT2" => qso.Qso.IsMultiplier2, + "ISMULT3" => qso.Qso.IsMultiplier3, + "EXCHISNUM" => IsNumber(qso.Qso.Exchange1), + "EXCHISNOTNUM" => qso.Qso.Exchange1.Trim().Length > 0 && !IsNumber(qso.Qso.Exchange1), + "EXCHIS_EMPTY" => qso.Qso.Exchange1.Trim().Length == 0, + "MISCISNUM" => IsNumber(qso.Qso.MiscText), + _ => MatchesLonger(qso, test), + }; + } + + private static bool MatchesLonger(QsoContext qso, string test) + { + if (test.Length > 7) + { + string value = test[7..]; + switch (test[..6]) + { + case "SECTIS": + return Same(qso.Qso.Section, value); + case "EXCHIS": + return Same(qso.Qso.Exchange1, value); + case "MISCIS": + return Same(qso.Qso.MiscText, value); + } + } + int plus = test.IndexOf('+'); + if (plus > 0) + { + return Matches(qso, test[..plus], "") && IsBand(qso, test[(plus + 1)..]); + } + return IsBand(qso, test) + || IsMode(qso, test) + || IsContinent(qso, test) + || IsCallSuffix(qso, test) + || Same(qso.CountryPrefix, test) + || Same(qso.Qso.Call.Text, test); + } + + /// The band names a `.udc` file uses are the band names: `160M`, `70CM`. + /// A points condition may leave the `M` off — `SAMECONTINENT+160`. + private static bool IsBand(QsoContext qso, string test) => + qso.Band is not null && + (Same(qso.Band.Name, test) || Same(qso.Band.Name, test + "M")); + + private static bool IsMode(QsoContext qso, string test) => test switch + { + "CW" => qso.ModeCategory == ModeCategory.Cw, + "SSB" or "USB" or "LSB" => qso.ModeCategory == ModeCategory.Phone, + "DIGI" => qso.ModeCategory == ModeCategory.Digital, + "RTTY" or "PSK" or "FT8" or "FT4" or "FM" or "AM" => Same(qso.Qso.Mode.Name, test), + _ => false, + }; + + private static bool IsContinent(QsoContext qso, string test) => test switch + { + "EU" or "NA" or "SA" or "AS" or "AF" or "OC" or "AN" => Same(qso.Continent, test), + "MYCONTINENT" => qso.IsSameContinent, + _ => false, + }; + + /// `/P`, `/M` and `/QRP` match what the station signs after the last slash. + private static bool IsCallSuffix(QsoContext qso, string test) + { + if (!test.StartsWith('/')) + { + return false; + } + string call = qso.Qso.Call.Text; + int slash = call.LastIndexOf('/'); + return slash >= 0 && Same(call[slash..], test); + } + + private static bool SameGridField(string theirs, string mine) => + theirs.Trim().Length >= 4 && mine.Trim().Length >= 4 && + Same(theirs.Trim()[..4], mine.Trim()[..4]); + + private static bool IsNumber(string text) + { + string trimmed = text.Trim(); + return trimmed.Length > 0 && trimmed.All(char.IsAsciiDigit); + } + + private static bool Same(string left, string right) => + left.Trim().Equals(right.Trim(), StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Nonemm.Contests/Udc/UdcMultipliers.cs b/src/Nonemm.Contests/Udc/UdcMultipliers.cs index 864e61b..da4065c 100644 --- a/src/Nonemm.Contests/Udc/UdcMultipliers.cs +++ b/src/Nonemm.Contests/Udc/UdcMultipliers.cs @@ -3,50 +3,102 @@ using Nonemm.Core; namespace Nonemm.Contests.Udc; /// One of a user-defined contest's up-to-three multipliers: where its value -/// comes from and how often it may be counted. +/// comes from, which stations may bring it in, and how often it may be counted. +/// +/// Left out: `CallHist`, which counts a value from the call history file, and +/// needs that file loaded before a multiplier can be worked out. public sealed class UdcMultiplier { private readonly int index; private readonly string source; private readonly UdcMultiplierScope scope; - private readonly IReadOnlyList onlyForCountries; + private readonly int weight; + private readonly bool skipsMyOwn; + private readonly IReadOnlyList onlyFor; + private readonly IReadOnlyList notFor; + private readonly IReadOnlyList onlyForSections; - public UdcMultiplier( - int index, - string source, - UdcMultiplierScope scope, - IReadOnlyList onlyForCountries) + public UdcMultiplier(int index, UdcFile file, UdcMultiplierScope shared) { this.index = index; - this.source = source; - this.scope = scope; - this.onlyForCountries = onlyForCountries; + string suffix = index == 1 ? "" : index.ToString(); + source = file.Text($"MultSqlString{suffix}").Trim().ToUpperInvariant(); + UdcMultiplierScope own = (UdcMultiplierScope)file.Number($"IsMult{index}Per", 0); + scope = own != UdcMultiplierScope.None ? own + : shared != UdcMultiplierScope.None ? shared + : UdcMultiplierScope.PerBand; + weight = file.Number($"MultMult{suffix}", 1); + skipsMyOwn = file.Flag($"DoNotCountMeAsMult{suffix}", false); + onlyFor = file.List($"CountMultOnlyFor{suffix}"); + notFor = file.List($"DoNotCountMultOnlyFor{suffix}"); + onlyForSections = file.List($"CountMultOnlyForSec{suffix}"); } + public bool IsDefined => source.Length > 0; + + /// What one of these is worth in the score. `MultMult` is a weight, not a + /// switch: a contest can count a multiplier twice, and one weighted zero + /// still shows as worked and adds nothing. + public int Weight => weight; + public Multiplier? For(QsoContext qso) { - if (scope == UdcMultiplierScope.None) - { - return null; - } - if (onlyForCountries.Count > 0 && - !onlyForCountries.Contains(qso.CountryPrefix, StringComparer.OrdinalIgnoreCase)) - { - return null; - } string? value = ValueFor(qso); - return string.IsNullOrEmpty(value) ? null : new Multiplier(index, value, ScopeKey(qso)); + if (scope == UdcMultiplierScope.None || string.IsNullOrEmpty(value)) + { + return null; + } + return Counts(qso, value) ? new Multiplier(index, value, ScopeKey(qso), weight) : null; } - private string? ValueFor(QsoContext qso) => source.ToUpperInvariant() switch + /// `CountMultOnlyFor` and `DoNotCountMultOnlyFor` hold country prefixes or + /// continent abbreviations, and for a prefix multiplier the prefixes + /// themselves, so a station is matched against all three. + private bool Counts(QsoContext qso, string value) + { + if (skipsMyOwn && IsMyOwn(qso, value)) + { + return false; + } + if (onlyFor.Count > 0 && !onlyFor.Any(p => Names(qso, value, p))) + { + return false; + } + if (notFor.Any(p => Names(qso, value, p))) + { + return false; + } + return onlyForSections.Count == 0 + || onlyForSections.Contains(qso.Qso.Section.Trim(), StringComparer.OrdinalIgnoreCase); + } + + private bool IsMyOwn(QsoContext qso, string value) => + IsPrefixSource + ? qso.Me.Callsign.StartsWith(value, StringComparison.OrdinalIgnoreCase) + : qso.IsSameCountry; + + private bool Names(QsoContext qso, string value, string wanted) => + wanted.Equals(qso.CountryPrefix, StringComparison.OrdinalIgnoreCase) + || wanted.Equals(qso.Continent, StringComparison.OrdinalIgnoreCase) + || (IsPrefixSource && wanted.Equals(value, StringComparison.OrdinalIgnoreCase)); + + private bool IsPrefixSource => source is "WPXPREFIX" or "2LPREFIX"; + + private string? ValueFor(QsoContext qso) => source switch { "COUNTRYPREFIX" => qso.CountryPrefix, + "EU_COUNTRY" or "AS_COUNTRY" or "NA_COUNTRY" or "SA_COUNTRY" or "AF_COUNTRY" or "OC_COUNTRY" => + CountryOnContinent(qso, source[..2]), "WPXPREFIX" => qso.Qso.Call.WpxPrefix(), + "2LPREFIX" => Truncate(qso.Qso.Call.Text, 2), + "LASTLETTER" => LastLetter(qso.Qso.Call.Text), "SECTION" or "SECT" => Upper(qso.Qso.Section), "EXCHANGE" or "EXCH" => Upper(qso.Qso.Exchange1), "MISC" or "MISCTEXT" => Upper(qso.Qso.MiscText), + "COMMENT" => Upper(qso.Qso.Comment), "CALLSIGN" => qso.Qso.Call.Text, - "CQZONE" or "ZN" => qso.Qso.Zone > 0 ? qso.Qso.Zone.ToString() : null, + "CQZONE" or "ZN" => ZoneName(qso.Qso.Zone, qso.CqZone), + "IARUZONE" => ZoneName(qso.Qso.Zone, qso.ItuZone), "GRID" => Truncate(qso.Qso.GridSquare, 4), "SGRID" => Truncate(qso.Qso.GridSquare, 6), "FIELD" => Truncate(qso.Qso.GridSquare, 2), @@ -55,6 +107,16 @@ public sealed class UdcMultiplier _ => null, }; + private static string? CountryOnContinent(QsoContext qso, string continent) => + continent.Equals(qso.Continent, StringComparison.OrdinalIgnoreCase) ? qso.CountryPrefix : null; + + /// The zone the operator typed, and the country file's zone when the box is + /// empty. + private static string? ZoneName(int received, int fromCountryFile) => + received > 0 ? received.ToString() + : fromCountryFile > 0 ? fromCountryFile.ToString() + : null; + private string ScopeKey(QsoContext qso) => scope switch { UdcMultiplierScope.PerBand => qso.Band?.Name ?? "", @@ -65,6 +127,12 @@ public sealed class UdcMultiplier private static string Upper(string text) => text.Trim().ToUpperInvariant(); + private static string? LastLetter(string call) + { + string trimmed = call.Trim().ToUpperInvariant(); + return trimmed.Length == 0 ? null : trimmed[^1..]; + } + private static string? Truncate(string text, int length) { string trimmed = text.Trim().ToUpperInvariant(); diff --git a/src/Nonemm.Contests/Udc/UdcPoints.cs b/src/Nonemm.Contests/Udc/UdcPoints.cs index e294cf1..eef5b64 100644 --- a/src/Nonemm.Contests/Udc/UdcPoints.cs +++ b/src/Nonemm.Contests/Udc/UdcPoints.cs @@ -1,16 +1,27 @@ -using Nonemm.Core; +using System.Globalization; namespace Nonemm.Contests.Udc; -/// Reads N1MM's `PointsPerContact` setting. A plain number scores every contact -/// the same; a comma list gives a condition and a score in turn, and the first -/// condition in the list that matches wins. N1MM's help describes an order of -/// precedence, but its code walks the list left to right, and a file written -/// for N1MM has to score the same here. +/// Reads N1MM's `PointsPerContact` setting, and the same format in +/// `BonusPoints`. A plain number scores every contact the same; a comma list +/// gives a condition and a score in turn, and the first condition in the list +/// that matches wins. N1MM's help describes an order of precedence, but its +/// code walks the list left to right, and a file written for N1MM has to score +/// the same here. +/// +/// Two more forms. `Exchange, *` and `Misc, *` score the contact the number the +/// operator typed in that box. A list of `low/high/points` ranges separated by +/// semicolons scores by the distance to the other station's grid square: a +/// contact inside a range scores the range's points on top of whatever the +/// comma list gave, and one outside every range scores the distance itself in +/// kilometres, which is what N1MM does. public sealed class UdcPoints { private readonly int flat; private readonly List<(string Condition, int Points)> rules = []; + private readonly List<(int Low, int High, int Points)> ranges = []; + private readonly bool exchangeIsPoints; + private readonly bool miscIsPoints; public UdcPoints(string setting) { @@ -22,22 +33,51 @@ public sealed class UdcPoints } for (int at = 0; at + 1 < parts.Length; at += 2) { - if (int.TryParse(parts[at + 1].Trim(), out int points)) + string condition = parts[at].Trim(); + string score = parts[at + 1].Trim(); + if (score == "*") { - rules.Add((parts[at].Trim(), points)); + exchangeIsPoints |= condition.Equals("Exchange", StringComparison.OrdinalIgnoreCase); + miscIsPoints |= condition.Equals("Misc", StringComparison.OrdinalIgnoreCase); + } + else if (int.TryParse(score, NumberStyles.Integer, CultureInfo.InvariantCulture, out int points)) + { + rules.Add((condition, points)); } } + ReadRanges(setting); } - public int For(QsoContext qso) + public int For(QsoContext qso, string mySentExchange) { - if (rules.Count == 0) + int points = Typed(qso) ?? Ruled(qso, mySentExchange); + if (ranges.Count == 0) { - return flat; + return points; } + int km = (int)Math.Round(qso.DistanceKm + 0.5); + int? inRange = RangePoints(km); + return inRange is null ? km : points + inRange.Value; + } + + private int? Typed(QsoContext qso) + { + if (exchangeIsPoints && int.TryParse(qso.Qso.Exchange1.Trim(), out int fromExchange)) + { + return fromExchange; + } + if (miscIsPoints && int.TryParse(qso.Qso.MiscText.Trim(), out int fromMisc)) + { + return fromMisc; + } + return null; + } + + private int Ruled(QsoContext qso, string mySentExchange) + { foreach ((string condition, int points) in rules) { - if (Tests.Any(test => test(qso, condition))) + if (UdcCondition.Matches(qso, condition, mySentExchange)) { return points; } @@ -45,44 +85,30 @@ public sealed class UdcPoints return flat; } - private static readonly Func[] Tests = - [ - MatchesBand, - MatchesMode, - MatchesContinent, - MatchesCountry, - ]; - - private static bool MatchesBand(QsoContext qso, string condition) => - qso.Band is not null && - condition.EndsWith('m') && - MeterName(qso.Band).Equals(condition, StringComparison.OrdinalIgnoreCase); - - private static bool MatchesMode(QsoContext qso, string condition) => condition.ToUpperInvariant() switch + private int? RangePoints(int km) { - "CW" => qso.ModeCategory == ModeCategory.Cw, - "SSB" => qso.ModeCategory == ModeCategory.Phone, - "DIGI" => qso.ModeCategory == ModeCategory.Digital, - "RTTY" or "PSK" or "FT8" or "FT4" or "FM" or "AM" => - qso.Qso.Mode.Name.Equals(condition, StringComparison.OrdinalIgnoreCase), - _ => false, - }; + foreach ((int low, int high, int points) in ranges) + { + if (km >= low && km <= high) + { + return points; + } + } + return null; + } - private static bool MatchesContinent(QsoContext qso, string condition) => condition.ToUpperInvariant() switch + private void ReadRanges(string setting) { - "SAMECONTINENT" => qso.IsSameContinent, - "OTHERCONTINENT" => !qso.IsSameContinent, - "EU" or "NA" or "SA" or "AS" or "AF" or "OC" or "AN" => - qso.Continent.Equals(condition, StringComparison.OrdinalIgnoreCase), - _ => false, - }; - - private static bool MatchesCountry(QsoContext qso, string condition) => - condition.Equals("MyCountry", StringComparison.OrdinalIgnoreCase) - ? qso.IsSameCountry - : qso.CountryPrefix.Equals(condition, StringComparison.OrdinalIgnoreCase); - - /// The band names UDC files use: `160m`, `80m`, `70cm` and so on, which are - /// the band names lower-cased. - private static string MeterName(Band band) => band.Name.ToLowerInvariant(); + foreach (string entry in setting.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + string[] bounds = entry.Split('/'); + if (bounds.Length == 3 && + int.TryParse(bounds[0].Trim(), out int low) && + int.TryParse(bounds[1].Trim(), out int high) && + int.TryParse(bounds[2].Trim(), out int points)) + { + ranges.Add((low, high, points)); + } + } + } } diff --git a/src/Nonemm.Contests/Udc/UdcPointsMultiplier.cs b/src/Nonemm.Contests/Udc/UdcPointsMultiplier.cs new file mode 100644 index 0000000..39778af --- /dev/null +++ b/src/Nonemm.Contests/Udc/UdcPointsMultiplier.cs @@ -0,0 +1,150 @@ +using System.Globalization; +using Nonemm.Core; + +namespace Nonemm.Contests.Udc; + +/// The `PointsMultBy…` settings, which scale what a contact scores. Each +/// setting is a list of a key and a factor in turn, and the first key that +/// matches the contact gives the factor. The settings multiply together, so a +/// contest can pay double on 160 metres and triple outside the continent at the +/// same time. +/// +/// `PointsMultAtTimeGMT` and `PointsMultAtTimeLocal` are a start time, an end +/// time and a factor, both times `HHMM`. A window whose end is before its start +/// runs over midnight. +/// +/// `PowerMult` is the operator's own entry: a list of `QRPP`, `QRP`, `LP` or +/// `HP` and a factor, which multiplies the whole score. +/// +/// `PointsMultByCategory` is not about the operator's own entry: it scales a +/// contact with a station signing QRP, and its keys are the last three or four +/// characters of that station's callsign — `QRP` and `/QRP`. `PowerMult`, +/// which scales the whole score by the operator's own power category, is in +/// `UserDefinedContest` where the score is added up. +public sealed class UdcPointsMultiplier +{ + private readonly List<(string Key, double Factor)> byBand; + private readonly List<(string Key, double Factor)> byMode; + private readonly List<(string Key, double Factor)> byContinent; + private readonly List<(string Key, double Factor)> byCountry; + private readonly List<(int From, int To, double Factor)> atTimeUtc; + private readonly List<(int From, int To, double Factor)> atTimeLocal; + private readonly List<(string Key, double Factor)> byCategory; + private readonly List<(string Key, double Factor)> byPower; + + public UdcPointsMultiplier(UdcFile file) + { + byBand = ReadPairs(file.Text("PointsMultByBand")); + byMode = ReadPairs(file.Text("PointsMultByMode")); + byContinent = ReadPairs(file.Text("PointsMultByContinent")); + byCountry = ReadPairs(file.Text("PointsMultByCountry")); + atTimeUtc = ReadWindows(file.Text("PointsMultAtTimeGMT")); + atTimeLocal = ReadWindows(file.Text("PointsMultAtTimeLocal")); + byCategory = ReadPairs(file.Text("PointsMultByCategory")); + byPower = ReadPairs(file.Text("PowerMult")); + } + + public double For(QsoContext qso) => + First(byBand, qso) * First(byMode, qso) * First(byContinent, qso) * First(byCountry, qso) * + InWindow(atTimeUtc, qso.Qso.TimestampUtc) * + InWindow(atTimeLocal, qso.Qso.TimestampUtc.ToLocalTime()) * + ForCategory(qso); + + /// What the whole score is multiplied by, from the power category of the + /// entry. The setting names the categories `LP` and `HP`, and the log + /// stores them as `LOW` and `HIGH`. + public double ForEntry(ContestEntry entry) + { + foreach ((string key, double factor) in byPower) + { + string category = key.Trim().ToUpperInvariant() switch + { + "LP" => "LOW", + "HP" => "HIGH", + string other => other, + }; + if (category.Equals(entry.PowerCategory.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return factor; + } + } + return 1.0; + } + + /// Only a station signing QRP is scaled, which is N1MM's rule. + private double ForCategory(QsoContext qso) + { + string call = qso.Qso.Call.Text.ToUpperInvariant(); + if (!call.Contains("QRP", StringComparison.Ordinal)) + { + return 1.0; + } + foreach ((string key, double factor) in byCategory) + { + if (call.EndsWith(key.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return factor; + } + } + return 1.0; + } + + private static double First(List<(string Key, double Factor)> pairs, QsoContext qso) + { + foreach ((string key, double factor) in pairs) + { + if (UdcCondition.Matches(qso, key, "")) + { + return factor; + } + } + return 1.0; + } + + private static double InWindow(List<(int From, int To, double Factor)> windows, DateTime at) + { + int time = (at.Hour * 100) + at.Minute; + foreach ((int from, int to, double factor) in windows) + { + bool inside = from <= to ? time >= from && time <= to : time >= from || time <= to; + if (inside) + { + return factor; + } + } + return 1.0; + } + + private static List<(string, double)> ReadPairs(string setting) + { + string[] parts = setting.Split(',', StringSplitOptions.RemoveEmptyEntries); + List<(string, double)> pairs = []; + for (int at = 0; at + 1 < parts.Length; at += 2) + { + if (TryFactor(parts[at + 1], out double factor)) + { + pairs.Add((parts[at].Trim(), factor)); + } + } + return pairs; + } + + private static List<(int, int, double)> ReadWindows(string setting) + { + string[] parts = setting.Split(',', StringSplitOptions.RemoveEmptyEntries); + List<(int, int, double)> windows = []; + for (int at = 0; at + 2 < parts.Length; at += 3) + { + if (int.TryParse(parts[at].Trim(), out int from) && + int.TryParse(parts[at + 1].Trim(), out int to) && + TryFactor(parts[at + 2], out double factor)) + { + windows.Add((from, to, factor)); + } + } + return windows; + } + + private static bool TryFactor(string text, out double factor) => + double.TryParse(text.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out factor); +} diff --git a/src/Nonemm.Contests/Udc/UserDefinedContest.cs b/src/Nonemm.Contests/Udc/UserDefinedContest.cs index 60c5fc9..c7c857a 100644 --- a/src/Nonemm.Contests/Udc/UserDefinedContest.cs +++ b/src/Nonemm.Contests/Udc/UserDefinedContest.cs @@ -9,15 +9,21 @@ public sealed class UserDefinedContest : Contest { private readonly UdcFile file; private readonly UdcPoints points; + private readonly UdcPoints bonusPoints; + private readonly UdcPointsMultiplier pointsMultiplier; private readonly IReadOnlyList multipliers; private readonly IReadOnlyList exchangeFields; + private readonly IReadOnlyList workableStations; public UserDefinedContest(UdcFile file) { this.file = file; points = new UdcPoints(file.Text("PointsPerContact", "1")); + bonusPoints = new UdcPoints(file.Text("BonusPoints")); + pointsMultiplier = new UdcPointsMultiplier(file); multipliers = ReadMultipliers(file); exchangeFields = ReadExchangeFields(file); + workableStations = file.List("IsWorkable"); } public static UserDefinedContest Load(string path) => @@ -33,6 +39,7 @@ public sealed class UserDefinedContest : Contest public IReadOnlyList MultiplierNames => new[] { "Multiplier1Name", "Multiplier2Name", "Multiplier3Name" } + .Take(multipliers.Count) .Select(key => file.Text(key)) .Where(name => name.Length > 0) .ToList(); @@ -45,9 +52,27 @@ public sealed class UserDefinedContest : Contest _ => DupeScope.PerBand, }; + /// `DupeSqlString` names one more field that makes a contact distinct: a + /// station worked again with a different section, exchange, mode or grid + /// square is not a dupe. + public string ExtraDupeKey(Qso qso) => file.Number("DupeSqlString", 0) switch + { + 1 => qso.Section.Trim().ToUpperInvariant(), + 2 => qso.Exchange1.Trim().ToUpperInvariant(), + 3 => qso.Mode.Name, + 4 => qso.GridSquare.Trim().ToUpperInvariant(), + _ => "", + }; + public bool HasSerialNumbers => file.Text("DefaultContestExchange").StartsWith("001", StringComparison.Ordinal); + public bool ShowsWarcBands => file.Flag("ShowWarcBands", false); + + public bool UsesItuZones => + file.Text("ZoneType", "CQ").StartsWith("IARU", StringComparison.OrdinalIgnoreCase) + || file.Text("ZoneType", "CQ").StartsWith("ITU", StringComparison.OrdinalIgnoreCase); + public IReadOnlyList Modes => file.Text("Mode", "CW").ToUpperInvariant() switch { "CW" => [ModeCategory.Cw], @@ -58,13 +83,37 @@ public sealed class UserDefinedContest : Contest public string SentExchangeFor(StationInfo me) => file.Text("DefaultContestExchange", "599"); - public int PointsFor(QsoContext qso) => points.For(qso); + public int PointsFor(QsoContext qso) + { + if (!IsWorkable(qso)) + { + return 0; + } + string sent = SentExchange(qso); + double scored = points.For(qso, sent) * pointsMultiplier.For(qso); + return (int)Math.Round(scored) + bonusPoints.For(qso, sent); + } + + /// What this station sends: the exchange the operator set up for the + /// entry, and the contest's own default until they have. + private string SentExchange(QsoContext qso) => + qso.Entry.SentExchange.Length > 0 ? qso.Entry.SentExchange : SentExchangeFor(qso.Me); public IReadOnlyList MultipliersFor(QsoContext qso) => - multipliers.Select(m => m.For(qso)).OfType().ToList(); + IsWorkable(qso) + ? multipliers.Select(m => m.For(qso)).OfType().ToList() + : []; - public int TotalScore(ScoreTally tally) => - multipliers.Count == 0 ? tally.Points : tally.Points * tally.TotalMultipliers; + /// A contest whose multipliers are all weighted zero scores its points, + /// and one that counts multipliers scores points times at least one, so a + /// log with no multiplier worked yet is not worth nothing. + public int TotalScore(ScoreTally tally, ContestEntry entry) + { + int total = multipliers.Any(m => m.Weight != 0) + ? tally.Points * Math.Max(1, tally.TotalMultipliers) + : tally.Points; + return (int)Math.Round(total * pointsMultiplier.ForEntry(entry)); + } public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => new( @@ -79,6 +128,34 @@ public sealed class UserDefinedContest : Contest new CabrilloField(ReceivedExchangePart(qso), 6), ]); + /// `IsWorkable` names the stations the contest counts: a continent, my own + /// country, everything but my own country, or a list of country prefixes. A + /// station outside it scores nothing and brings no multiplier. + private bool IsWorkable(QsoContext qso) + { + if (workableStations.Count == 0) + { + return true; + } + return workableStations.Any(rule => rule.ToUpperInvariant() switch + { + "ANY" => true, + "MYCONTINENTONLY" => qso.IsSameContinent, + "MYCOUNTRYONLY" => qso.IsSameCountry, + "EXCEPTMYCOUNTRY" => !qso.IsSameCountry, + "NAONLY" => Continent(qso, "NA"), + "SAONLY" => Continent(qso, "SA"), + "EUONLY" => Continent(qso, "EU"), + "ASIAONLY" => Continent(qso, "AS"), + "AFONLY" => Continent(qso, "AF"), + "OCONLY" => Continent(qso, "OC"), + _ => rule.Equals(qso.CountryPrefix, StringComparison.OrdinalIgnoreCase), + }); + } + + private static bool Continent(QsoContext qso, string continent) => + continent.Equals(qso.Continent, StringComparison.OrdinalIgnoreCase); + private string SentExchangePart(Qso qso) => HasSerialNumbers ? $"{qso.SentNumber:000}" : file.Text("DefaultContestExchange"); @@ -88,29 +165,21 @@ public sealed class UserDefinedContest : Contest : qso.ReceivedNumber > 0 ? $"{qso.ReceivedNumber:000}" : qso.MiscText; + /// `NumMults` caps how many of the three the contest counts. A file that + /// leaves it out counts every `MultSqlString` it defines. private static IReadOnlyList 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) + UdcMultiplier multiplier = new(index, file, shared); + if (multiplier.IsDefined) { - continue; + found.Add(multiplier); } - UdcMultiplierScope scope = (UdcMultiplierScope)file.Number($"IsMult{index}Per", 0); - if (scope == UdcMultiplierScope.None) - { - scope = shared == UdcMultiplierScope.None ? UdcMultiplierScope.PerBand : shared; - } - found.Add(new UdcMultiplier( - index, - source, - scope, - file.List(index == 1 ? "CountMultOnlyFor" : $"CountMultOnlyFor{index}"))); } - return found; + return found.Take(file.Number("NumMults", found.Count)).ToList(); } /// `EntryWindowInfo` names the boxes; `FrameText` labels them. Only the diff --git a/src/Nonemm.Core/ContestEntry.cs b/src/Nonemm.Core/ContestEntry.cs new file mode 100644 index 0000000..d69ffaa --- /dev/null +++ b/src/Nonemm.Core/ContestEntry.cs @@ -0,0 +1,34 @@ +namespace Nonemm.Core; + +/// The entry the operator declared: the categories the sponsor's header asks +/// for, and the exchange this station sends. Contests score by these — a QRP +/// entry can be worth a multiple of what the same contacts score at high +/// power, and a contest can pay by what the station itself sends. +/// +/// The values are the Cabrillo category names, which is what the log file +/// stores and what N1MM compares against. +public sealed record ContestEntry +{ + public string OperatorCategory { get; init; } = "SINGLE-OP"; + + public string BandCategory { get; init; } = "ALL"; + + public string PowerCategory { get; init; } = "HIGH"; + + public string ModeCategory { get; init; } = ""; + + public string OverlayCategory { get; init; } = ""; + + public string StationCategory { get; init; } = ""; + + public string AssistedCategory { get; init; } = "NON-ASSISTED"; + + public string TransmitterCategory { get; init; } = "ONE"; + + public string TimeCategory { get; init; } = ""; + + /// What this station sends, as the operator typed it into the contest + /// setup. Empty until a contest is open, and then whatever N1MM would put + /// in the sent exchange box. + public string SentExchange { get; init; } = ""; +} diff --git a/src/Nonemm.Session/ContestSession.cs b/src/Nonemm.Session/ContestSession.cs index ef123a0..856f707 100644 --- a/src/Nonemm.Session/ContestSession.cs +++ b/src/Nonemm.Session/ContestSession.cs @@ -30,7 +30,7 @@ public sealed class ContestSession Contest = contest; Instance = instance; Me = me; - Log = new ContestLog(contest, me, countries); + Log = new ContestLog(contest, me, countries, instance.Entry); Log.Restore(store.Qsos(instance.ContestNumber)); Editor = new QsoEditor(contest, me, countries); SentNumber = NextSentNumber(); diff --git a/src/Nonemm.Storage/ContestInstance.cs b/src/Nonemm.Storage/ContestInstance.cs index d90e1cf..712d25d 100644 --- a/src/Nonemm.Storage/ContestInstance.cs +++ b/src/Nonemm.Storage/ContestInstance.cs @@ -1,3 +1,5 @@ +using Nonemm.Core; + namespace Nonemm.Storage; /// One running of a contest in the log: the entry categories, the sent exchange @@ -48,4 +50,19 @@ public sealed record ContestInstance public string Soapbox { get; init; } = ""; public long ClaimedScore { get; init; } + + /// The entry as the contest rules read it. + public ContestEntry Entry => new() + { + OperatorCategory = OperatorCategory, + BandCategory = BandCategory, + PowerCategory = PowerCategory, + ModeCategory = ModeCategory, + OverlayCategory = OverlayCategory, + StationCategory = StationCategory, + AssistedCategory = AssistedCategory, + TransmitterCategory = TransmitterCategory, + TimeCategory = TimeCategory, + SentExchange = SentExchange, + }; } diff --git a/src/Nonemm.Storage/QsoColumns.cs b/src/Nonemm.Storage/QsoColumns.cs index 18295b9..761dae1 100644 --- a/src/Nonemm.Storage/QsoColumns.cs +++ b/src/Nonemm.Storage/QsoColumns.cs @@ -111,10 +111,12 @@ internal static class QsoColumns IsClaimed = Flag(row, "CLAIMEDQSO"), }; + /// N1MM writes a single space into a column it has nothing for, so an + /// empty value has to be read as empty rather than as one space. private static string Text(SqliteDataReader row, string column) { int at = row.GetOrdinal(column); - return row.IsDBNull(at) ? "" : row.GetString(at); + return row.IsDBNull(at) ? "" : row.GetString(at).Trim(); } private static double Number(SqliteDataReader row, string column) diff --git a/tests/Nonemm.Contests.Tests/ArrlDxTests.cs b/tests/Nonemm.Contests.Tests/ArrlDxTests.cs index 75e89b0..9631d1b 100644 --- a/tests/Nonemm.Contests.Tests/ArrlDxTests.cs +++ b/tests/Nonemm.Contests.Tests/ArrlDxTests.cs @@ -12,7 +12,7 @@ public class ArrlDxTests public void DxWorkingWvieScoresThree() { ContestLog log = LogFor(TestLog.Germany); - Assert.Equal(3, log.Judge(TestLog.Contact("K1ABC", exchange: "CT")).Points); + Assert.Equal(3, log.Judge(TestLog.Contact("K1ABC", section: "CT")).Points); } [Fact] @@ -33,8 +33,8 @@ public class ArrlDxTests 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); + Assert.Equal("CT", log.Judge(TestLog.Contact("K1ABC", section: "CT")).NewMultipliers.Single().Value); + Assert.Empty(log.Judge(TestLog.Contact("K1ABC", section: "XX")).NewMultipliers); } [Fact] @@ -48,8 +48,28 @@ public class ArrlDxTests 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); + log.Add(TestLog.Contact("K1ABC", section: "CT")); + Assert.Empty(log.Judge(TestLog.Contact("K2ABC", section: "CT")).NewMultipliers); + Assert.Single(log.Judge(TestLog.Contact("K2ABC", 7_025, section: "CT")).NewMultipliers); + } + + /// A Canadian station sends the postal code or the older abbreviation, and + /// Newfoundland and Labrador is one province either way. N1MM counts `NF` + /// and `LB` as two multipliers, which is two for one province. + [Fact] + public void NewfoundlandAndLabradorCountOnce() + { + ContestLog log = new(new ArrlDx(ModeCategory.Phone), TestLog.Germany, TestLog.CountryFile); + log.Add(TestLog.Contact("VO1XYZ", section: "NF")); + Assert.Empty(log.Judge(TestLog.Contact("VO2XYZ", section: "LB")).NewMultipliers); + } + + [Fact] + public void TheOlderQuebecAbbreviationIsQuebec() + { + ContestLog log = new(new ArrlDx(ModeCategory.Phone), TestLog.Germany, TestLog.CountryFile); + Assert.Equal( + "QC", + log.Judge(TestLog.Contact("VA2XYZ", section: "QB")).NewMultipliers.Single().Value); } } diff --git a/tests/Nonemm.Contests.Tests/CqWorldWideRttyTests.cs b/tests/Nonemm.Contests.Tests/CqWorldWideRttyTests.cs index cca8e5f..8b4201a 100644 --- a/tests/Nonemm.Contests.Tests/CqWorldWideRttyTests.cs +++ b/tests/Nonemm.Contests.Tests/CqWorldWideRttyTests.cs @@ -90,4 +90,24 @@ public class CqWorldWideRttyTests Assert.Equal(ExchangeSlot.Section, fields[2].Slot); Assert.False(fields[2].IsRequired); } + + /// The Canadian areas this contest counts are its own list, which splits + /// Newfoundland from Labrador. + [Theory] + [InlineData("NF")] + [InlineData("LB")] + public void TheContestsOwnCanadianAreasCount(string area) => + Assert.Contains( + LogFor(TestLog.Germany).Judge(Contact("VO1XYZ", 5, area)).NewMultipliers, + m => m.Index == 3 && m.Value == area); + + /// A maritime mobile station belongs to no country. CQ WW counts it for the + /// zone alone, and the contact still scores by continent. + [Fact] + public void MaritimeMobileScoresButBringsNoCountry() + { + Verdict verdict = LogFor(TestLog.Germany).Judge(Contact("IK2XYZ/MM", 15)); + Assert.Equal(2, verdict.Points); + Assert.DoesNotContain(verdict.NewMultipliers, m => m.Index == 2); + } } diff --git a/tests/Nonemm.Contests.Tests/CqWpxTests.cs b/tests/Nonemm.Contests.Tests/CqWpxTests.cs index 6319f16..301a8aa 100644 --- a/tests/Nonemm.Contests.Tests/CqWpxTests.cs +++ b/tests/Nonemm.Contests.Tests/CqWpxTests.cs @@ -66,4 +66,16 @@ public class CqWpxTests ContestLog log = LogFor(TestLog.Germany); Assert.Empty(log.Judge(TestLog.Contact("DL1ABC/MM")).NewMultipliers); } + + /// A tuning carrier logged by accident, or any contact in a mode the + /// contest does not run, scores nothing. The prefix still counts, which is + /// what N1MM logs. + [Fact] + public void AContactInAnotherModeScoresNothingAndKeepsItsMultiplier() + { + ContestLog log = new(new CqWpx(ModeCategory.Phone), TestLog.Germany, TestLog.CountryFile); + Verdict verdict = log.Judge(TestLog.Contact("IK2XYZ", mode: Modes.Rtty, number: 5)); + Assert.Equal(0, verdict.Points); + Assert.Single(verdict.NewMultipliers); + } } diff --git a/tests/Nonemm.Contests.Tests/UdcScoringTests.cs b/tests/Nonemm.Contests.Tests/UdcScoringTests.cs new file mode 100644 index 0000000..eda388c --- /dev/null +++ b/tests/Nonemm.Contests.Tests/UdcScoringTests.cs @@ -0,0 +1,264 @@ +using Nonemm.Contests.Udc; +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +/// The scoring settings a `.udc` file can carry: the points conditions, the +/// points multipliers, the bonus, and where a multiplier comes from. +public class UdcScoringTests +{ + private static UserDefinedContest Contest(params string[] settings) => + new(UdcFile.Parse("[Contest]\nName=SCORETEST\n" + string.Join("\n", settings))); + + private static ContestLog LogFor( + UserDefinedContest contest, + StationInfo? me = null, + ContestEntry? entry = null) => + new(contest, me ?? TestLog.Germany, TestLog.CountryFile, entry); + + private static int Points(UserDefinedContest contest, Qso qso, StationInfo? me = null) => + LogFor(contest, me).Judge(qso).Points; + + [Theory] + [InlineData("CT", 5)] + [InlineData("MA", 1)] + public void SectIsMatchesTheReceivedSection(string section, int expected) => + Assert.Equal( + expected, + Points( + Contest("PointsPerContact=SectIs_CT, 5, OtherContinent, 1"), + TestLog.Contact("K1ABC", section: section))); + + [Fact] + public void ExchIsNumSeparatesANumberFromAWord() => + Assert.Equal( + 3, + Points( + Contest("PointsPerContact=ExchIsNum, 3, ExchIsNotNum, 7"), + TestLog.Contact("K1ABC", exchange: "129"))); + + [Fact] + public void ExchIsEmptyScoresTheContactWithNoExchange() => + Assert.Equal( + 2, + Points(Contest("PointsPerContact=ExchIs_Empty, 2, ExchIsNum, 9"), TestLog.Contact("K1ABC"))); + + [Fact] + public void AStarScoresTheNumberInTheExchangeBox() => + Assert.Equal( + 42, + Points(Contest("PointsPerContact=Exchange, *"), TestLog.Contact("K1ABC", exchange: "42"))); + + [Fact] + public void MyCqZoneScoresAStationInMyOwnZone() => + Assert.Equal( + 1, + Points(Contest("PointsPerContact=MyCQZone, 1, OtherContinent, 4"), TestLog.Contact("DL9XYZ"))); + + /// `SAMECONTINENT+160` is the same continent on one band only. + [Fact] + public void AContinentConditionCanCarryABand() + { + UserDefinedContest contest = Contest("PointsPerContact=SameContinent+160, 6, SameContinent, 2"); + Assert.Equal(6, Points(contest, TestLog.Contact("IK2XYZ", 1_830))); + Assert.Equal(2, Points(contest, TestLog.Contact("IK2XYZ"))); + } + + [Fact] + public void PointsMultByBandScalesWhatTheContactScores() + { + UserDefinedContest contest = Contest("PointsPerContact=3", "PointsMultByBand=160M, 3, 20M, 1"); + Assert.Equal(9, Points(contest, TestLog.Contact("IK2XYZ", 1_830))); + Assert.Equal(3, Points(contest, TestLog.Contact("IK2XYZ"))); + } + + [Fact] + public void PointsMultAtTimeGmtScalesInsideItsWindow() + { + UserDefinedContest contest = Contest("PointsPerContact=2", "PointsMultAtTimeGMT=2300, 0300, 2"); + DateTime midnight = new(2026, 5, 30, 0, 30, 0, DateTimeKind.Utc); + Assert.Equal(4, Points(contest, TestLog.Contact("IK2XYZ", at: midnight))); + Assert.Equal(2, Points(contest, TestLog.Contact("IK2XYZ"))); + } + + [Fact] + public void BonusPointsAreAddedToWhatTheContactScores() => + Assert.Equal( + 26, + Points( + Contest("PointsPerContact=1", "BonusPoints=IK2XYZ, 25"), + TestLog.Contact("IK2XYZ"))); + + /// A contest can pay the bonus for a new multiplier, so the multiplier has + /// to be worked out before the points are. + [Fact] + public void BonusPointsCanBePaidForANewMultiplier() + { + UserDefinedContest contest = Contest( + "PointsPerContact=1", + "MultSqlString=CountryPrefix", + "IsMultPer=1", + "BonusPoints=IsMult1, 10"); + ContestLog log = LogFor(contest); + Assert.Equal(11, log.Judge(TestLog.Contact("IK2XYZ")).Points); + log.Add(TestLog.Contact("IK2XYZ")); + Assert.Equal(1, log.Judge(TestLog.Contact("IK3XYZ")).Points); + } + + [Fact] + public void ADistanceTableScoresByTheRangeTheContactFallsIn() + { + UserDefinedContest contest = Contest("PointsPerContact=0/100/7"); + StationInfo me = TestLog.Germany with { GridSquare = "JO60" }; + Qso near = TestLog.Contact("DL9XYZ") with { GridSquare = "JO60" }; + Qso far = TestLog.Contact("JA1XYZ") with { GridSquare = "PM95" }; + Assert.Equal(7, Points(contest, near, me)); + Assert.True(Points(contest, far, me) > 1_000); + } + + [Theory] + [InlineData("MultSqlString=2LPREFIX", "IK")] + [InlineData("MultSqlString=LastLetter", "Z")] + [InlineData("MultSqlString=EU_Country", "I")] + public void TheMultiplierValueComesFromTheSource(string setting, string expected) + { + UserDefinedContest contest = Contest(setting, "IsMultPer=4"); + Verdict verdict = LogFor(contest).Judge(TestLog.Contact("IK2XYZ")); + Assert.Equal(expected, Assert.Single(verdict.NewMultipliers).Value); + } + + [Fact] + public void ACountryOnAnotherContinentBringsNoContinentMultiplier() => + Assert.Empty( + LogFor(Contest("MultSqlString=EU_Country", "IsMultPer=4")) + .Judge(TestLog.Contact("JA1XYZ")) + .NewMultipliers); + + [Fact] + public void CountMultOnlyForTakesAContinentAsWellAsACountry() + { + UserDefinedContest contest = Contest( + "MultSqlString=CountryPrefix", + "IsMultPer=4", + "CountMultOnlyFor=EU"); + Assert.Single(LogFor(contest).Judge(TestLog.Contact("IK2XYZ")).NewMultipliers); + Assert.Empty(LogFor(contest).Judge(TestLog.Contact("JA1XYZ")).NewMultipliers); + } + + [Fact] + public void DoNotCountMultOnlyForLeavesThoseStationsOut() => + Assert.Empty( + LogFor(Contest("MultSqlString=CountryPrefix", "IsMultPer=4", "DoNotCountMultOnlyFor=JA")) + .Judge(TestLog.Contact("JA1XYZ")) + .NewMultipliers); + + [Fact] + public void DoNotCountMeAsMultLeavesOutMyOwnCountry() => + Assert.Empty( + LogFor(Contest("MultSqlString=CountryPrefix", "IsMultPer=4", "DoNotCountMeAsMult=True")) + .Judge(TestLog.Contact("DL9XYZ")) + .NewMultipliers); + + [Fact] + public void MultMultSaysWhatAMultiplierIsWorth() + { + ContestLog log = LogFor(Contest( + "PointsPerContact=1", + "MultSqlString=CountryPrefix", + "IsMultPer=4", + "MultMult=2")); + log.Add(TestLog.Contact("JA1XYZ")); + log.Add(TestLog.Contact("IK2XYZ")); + Assert.Equal(8, log.TotalScore); + } + + /// A multiplier weighted zero is still worked, and adds nothing. + [Fact] + public void MultMultZeroLeavesTheScoreAtThePoints() + { + ContestLog log = LogFor(Contest( + "PointsPerContact=3", + "MultSqlString=CountryPrefix", + "IsMultPer=4", + "MultMult=0")); + log.Add(TestLog.Contact("JA1XYZ")); + Assert.Equal(3, log.TotalScore); + } + + [Fact] + public void PowerMultScalesTheWholeScoreByTheEntryCategory() + { + UserDefinedContest contest = Contest("PointsPerContact=2", "PowerMult=QRP, 3, LP, 2, HP, 1"); + ContestLog qrp = LogFor(contest, entry: new ContestEntry { PowerCategory = "QRP" }); + ContestLog low = LogFor(contest, entry: new ContestEntry { PowerCategory = "LOW" }); + qrp.Add(TestLog.Contact("JA1XYZ")); + low.Add(TestLog.Contact("JA1XYZ")); + Assert.Equal(6, qrp.TotalScore); + Assert.Equal(4, low.TotalScore); + } + + /// `PointsMultByCategory` is about the station being worked, not the entry: + /// N1MM scales only a callsign signing QRP. + [Fact] + public void PointsMultByCategoryScalesAStationSigningQrp() + { + UserDefinedContest contest = Contest("PointsPerContact=2", "PointsMultByCategory=/QRP, 3"); + Assert.Equal(6, Points(contest, TestLog.Contact("JA1XYZ/QRP"))); + Assert.Equal(2, Points(contest, TestLog.Contact("JA1XYZ"))); + } + + [Fact] + public void MyExchangeComparesWhatThisStationSends() + { + UserDefinedContest contest = Contest("PointsPerContact=MyExchange, 1, OtherContinent, 5"); + ContestEntry entry = new() { SentExchange = "14" }; + Assert.Equal( + 1, + LogFor(contest, entry: entry).Judge(TestLog.Contact("JA1XYZ", section: "14")).Points); + Assert.Equal( + 5, + LogFor(contest, entry: entry).Judge(TestLog.Contact("JA1XYZ", section: "15")).Points); + } + + [Fact] + public void NumMultsCapsHowManyAreCounted() + { + UserDefinedContest contest = Contest( + "MultSqlString=CountryPrefix", + "MultSqlString2=Continent", + "IsMultPer=4", + "NumMults=1"); + Assert.Single(LogFor(contest).Judge(TestLog.Contact("JA1XYZ")).NewMultipliers); + } + + [Fact] + public void DupeSqlStringMakesASecondSectionADifferentContact() + { + UserDefinedContest contest = Contest("DupeType=1", "DupeSqlString=1"); + ContestLog log = LogFor(contest); + log.Add(TestLog.Contact("K1ABC", section: "CT")); + Assert.False(log.Judge(TestLog.Contact("K1ABC", section: "MA")).IsDupe); + Assert.True(log.Judge(TestLog.Contact("K1ABC", section: "CT")).IsDupe); + } + + [Fact] + public void IsWorkableLeavesOutTheStationsTheContestDoesNotCount() + { + UserDefinedContest contest = Contest( + "PointsPerContact=3", + "MultSqlString=CountryPrefix", + "IsMultPer=4", + "IsWorkable=EUonly"); + Verdict outside = LogFor(contest).Judge(TestLog.Contact("JA1XYZ")); + Assert.Equal(0, outside.Points); + Assert.Empty(outside.NewMultipliers); + Assert.Equal(3, Points(contest, TestLog.Contact("IK2XYZ"))); + } + + [Fact] + public void ZoneTypeIaruPutsTheContestOnItuZones() + { + Assert.True(Contest("ZoneType=IARU").UsesItuZones); + Assert.False(Contest("ZoneType=CQ").UsesItuZones); + } +}