diff --git a/README.md b/README.md index de3a98e..0a31fc5 100644 --- a/README.md +++ b/README.md @@ -495,6 +495,13 @@ save first. Points and the multiplier flags are shown but cannot be typed in — they are worked out from the rules every time the log changes, so anything typed there would be overwritten on the next edit. +The dialog also has the checkbox that takes a contact out of the claimed score. +An unclaimed contact scores no points and holds no multiplier, and the search +for dupes and multipliers passes over it, so a later contact with the same call +or the same multiplier counts in full. It still goes into the Cabrillo file, +under `X-QSO:` rather than `QSO:` — that is what N1MM writes, and the sponsors +read it as a contact the entrant is not claiming. + Correcting the country prefix by hand changes the score. The country file is a best guess for calls it has no rule for, so what the contact says now wins over what the file says. diff --git a/src/Nonemm.Contests/ContestLog.cs b/src/Nonemm.Contests/ContestLog.cs index de9ede0..d8ca863 100644 --- a/src/Nonemm.Contests/ContestLog.cs +++ b/src/Nonemm.Contests/ContestLog.cs @@ -47,6 +47,10 @@ public sealed class ContestLog /// What logging this contact right now would do, without logging it. public Verdict Judge(Qso candidate) { + if (!candidate.IsClaimed) + { + return new Verdict(false, 0, []); + } QsoContext context = ContextFor(candidate); if (!contest.IsContact(candidate)) { @@ -164,9 +168,23 @@ public sealed class ContestLog IsMultiplier3 = claimed.Any(m => m.Index == 3), }; + /// Adds the contact to the score and to the indexes that answer "worked + /// before?" and "new multiplier?". An unclaimed contact enters neither: + /// N1MM scores it as zero and leaves it out of both searches, so it takes + /// nothing away from a later contact with the same call or multiplier. A + /// contact marked as a dupe is the other way round — N1MM searches for + /// dupes and multipliers among it, and leaves it out of the score alone. private void Index(Qso qso, Verdict verdict) { - tally.AddQso(verdict.Points); + if (!qso.IsClaimed) + { + return; + } + bool counts = qso.IsInScore; + if (counts) + { + tally.AddQso(verdict.Points); + } if (!contest.IsContact(qso)) { return; @@ -175,7 +193,10 @@ public sealed class ContestLog foreach (Multiplier multiplier in verdict.NewMultipliers) { claimedMultipliers.Add(MultiplierKey(multiplier, qso)); - tally.AddMultiplier(multiplier); + if (counts) + { + tally.AddMultiplier(multiplier); + } } } diff --git a/src/Nonemm.Contests/Rules/Arrl10Metre.cs b/src/Nonemm.Contests/Rules/Arrl10Metre.cs index 975079e..32fb1ab 100644 --- a/src/Nonemm.Contests/Rules/Arrl10Metre.cs +++ b/src/Nonemm.Contests/Rules/Arrl10Metre.cs @@ -54,6 +54,13 @@ public sealed class Arrl10Metre : Contest ? me.State.Length > 0 ? me.State : me.Province : "001"; + /// What went out on this contact. A US or Canadian station sends the same + /// state or province every time; everyone else sends a serial, and ARRL + /// asks for the serial of that contact, so the Cabrillo file reads the + /// contact's own number and not the first one. N1MM sends it unpadded. + private string SentExchangeOn(Qso qso, StationInfo me) => + SendsState(me.CountryPrefix) ? SentExchangeFor(me) : $"{qso.SentNumber}"; + public int PointsFor(QsoContext qso) => qso.ModeCategory == ModeCategory.Cw ? 4 : 2; public IReadOnlyList MultipliersFor(QsoContext qso) @@ -75,7 +82,7 @@ public sealed class Arrl10Metre : Contest [ new CabrilloField(me.Callsign, 13), new CabrilloField(qso.SentReport, 3), - new CabrilloField(SentExchangeFor(me), 6), + new CabrilloField(SentExchangeOn(qso, me), 6), ], [ new CabrilloField(qso.Call.Text, 13), diff --git a/src/Nonemm.Contests/Rules/UkrainianDx.cs b/src/Nonemm.Contests/Rules/UkrainianDx.cs index a0ab885..2e08b4a 100644 --- a/src/Nonemm.Contests/Rules/UkrainianDx.cs +++ b/src/Nonemm.Contests/Rules/UkrainianDx.cs @@ -56,6 +56,13 @@ public sealed class UkrainianDx : Contest public string SentExchangeFor(StationInfo me) => IsUkrainian(me.CountryPrefix) ? me.County : "001"; + /// What went out on this contact. A Ukrainian station sends the same + /// oblast every time; everyone else sends a serial, so the Cabrillo file + /// reads the contact's own number and not the first one. N1MM pads it to + /// three digits. + private string SentExchangeOn(Qso qso, StationInfo me) => + IsUkrainian(me.CountryPrefix) ? me.County : $"{qso.SentNumber:000}"; + public int PointsFor(QsoContext qso) { if (IsUkrainian(qso.CountryPrefix)) @@ -93,7 +100,7 @@ public sealed class UkrainianDx : Contest [ new CabrilloField(me.Callsign, 13), new CabrilloField(qso.SentReport, 3), - new CabrilloField(SentExchangeFor(me), 6), + new CabrilloField(SentExchangeOn(qso, me), 6), ], [ new CabrilloField(qso.Call.Text, 13), diff --git a/src/Nonemm.Contests/Rules/YoDxHf.cs b/src/Nonemm.Contests/Rules/YoDxHf.cs index cf83e33..4bb96e0 100644 --- a/src/Nonemm.Contests/Rules/YoDxHf.cs +++ b/src/Nonemm.Contests/Rules/YoDxHf.cs @@ -55,6 +55,13 @@ public sealed class YoDxHf : Contest public string SentExchangeFor(StationInfo me) => me.CountryPrefix == "YO" ? me.County : "001"; + /// What went out on this contact. A YO station sends the same county + /// every time; everyone else sends a serial, so the Cabrillo file reads the + /// contact's own number and not the first one. N1MM pads it to three + /// digits. + private string SentExchangeOn(Qso qso, StationInfo me) => + me.CountryPrefix == "YO" ? me.County : $"{qso.SentNumber:000}"; + public int PointsFor(QsoContext qso) { if (qso.CountryPrefix == "YO") @@ -88,7 +95,7 @@ public sealed class YoDxHf : Contest [ new CabrilloField(me.Callsign, 13), new CabrilloField(qso.SentReport, 3), - new CabrilloField(SentExchangeFor(me), 6), + new CabrilloField(SentExchangeOn(qso, me), 6), ], [ new CabrilloField(qso.Call.Text, 13), diff --git a/src/Nonemm.Core/Qso.cs b/src/Nonemm.Core/Qso.cs index b74b34e..040963f 100644 --- a/src/Nonemm.Core/Qso.cs +++ b/src/Nonemm.Core/Qso.cs @@ -95,6 +95,12 @@ public sealed record Qso /// Cleared when the operator excludes a contact from the claimed score. public bool IsClaimed { get; init; } = true; + /// Counted in the score. N1MM sums points, contacts and multipliers over + /// the claimed contacts that are not marked as a dupe. Nothing here writes + /// the `D` contact type, but a log N1MM wrote can carry it. + public bool IsInScore => + IsClaimed && !ContactType.Equals("D", StringComparison.OrdinalIgnoreCase); + public Band? Band => Bands.ForFrequency(Frequency); public static string NewId() => Guid.NewGuid().ToString("N"); diff --git a/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs b/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs index 4525971..44e4b56 100644 --- a/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs +++ b/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs @@ -54,7 +54,7 @@ public sealed class CabrilloWriter } Line(text, "CREATED-BY", header.CreatedBy); - foreach (Qso qso in qsos.Where(q => q.IsClaimed).OrderBy(q => q.TimestampUtc)) + foreach (Qso qso in qsos.OrderBy(q => q.TimestampUtc)) { text.Append(QsoLine(qso)).Append("\r\n"); } @@ -65,7 +65,7 @@ public sealed class CabrilloWriter public string QsoLine(Qso qso) { CabrilloExchange exchange = contest.CabrilloExchange(qso, me, entry); - StringBuilder line = new(contest.IsContact(qso) ? "QSO: " : "QTC: "); + StringBuilder line = new(RecordName(qso)); line.Append(CabrilloBands.Designator(qso.Frequency).PadLeft(5)).Append(' '); line.Append(qso.Mode.CabrilloCode.PadRight(2)).Append(' '); line.Append(qso.TimestampUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append(' '); @@ -79,6 +79,18 @@ public sealed class CabrilloWriter return line.ToString(); } + /// A contact the operator has excluded from the claimed score is still + /// written, under `X-QSO:` rather than `QSO:`. That is what N1MM writes, + /// and the sponsors read it as a contact the entrant is not claiming. + private string RecordName(Qso qso) + { + if (!contest.IsContact(qso)) + { + return "QTC: "; + } + return qso.IsClaimed ? "QSO: " : "X-QSO: "; + } + /// Every column is the width the sponsor asks for and one space, and a /// value longer than its column is cut, which is what N1MM writes. private static void Append(StringBuilder line, IReadOnlyList fields) diff --git a/src/Nonemm.Session/StatisticsPivot.cs b/src/Nonemm.Session/StatisticsPivot.cs index 7aacdbf..464044c 100644 --- a/src/Nonemm.Session/StatisticsPivot.cs +++ b/src/Nonemm.Session/StatisticsPivot.cs @@ -57,7 +57,7 @@ public sealed class StatisticsPivot SortedSet columns = []; foreach (Qso qso in qsos) { - if (!includeDupes && !IsCounted(qso)) + if (!includeDupes && !qso.IsInScore) { continue; } @@ -102,10 +102,4 @@ public sealed class StatisticsPivot public int Total { get; } public bool IsEmpty => Rows.Count == 0; - - /// N1MM counts a contact when it is in the claimed score and is not marked - /// as a dupe. Nothing in this program writes the `D` contact type, but a log - /// N1MM wrote can carry it. - private static bool IsCounted(Qso qso) => - qso.IsClaimed && !qso.ContactType.Equals("D", StringComparison.OrdinalIgnoreCase); } diff --git a/tests/Nonemm.Contests.Tests/ExcludedContactTests.cs b/tests/Nonemm.Contests.Tests/ExcludedContactTests.cs new file mode 100644 index 0000000..02a44bb --- /dev/null +++ b/tests/Nonemm.Contests.Tests/ExcludedContactTests.cs @@ -0,0 +1,62 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +/// What N1MM does with a contact the operator has taken out of the claimed +/// score, and with one a log N1MM wrote has marked as a dupe. The two are not +/// the same: an unclaimed contact is left out of the score and out of both the +/// dupe search and the multiplier search, while a contact marked `D` still +/// takes a dupe and a multiplier away from a later contact. +public class ExcludedContactTests +{ + private static ContestLog LogFor() => + new(new CqWorldWide(ModeCategory.Cw), TestLog.Germany, TestLog.CountryFile); + + private static Qso Japan(string call = "JA1XYZ", DateTime? at = null) => + TestLog.Contact(call, zone: 25, at: at); + + private static readonly DateTime Later = new(2026, 5, 30, 13, 0, 0, DateTimeKind.Utc); + + [Fact] + public void AnUnclaimedContactScoresNothing() + { + ContestLog log = LogFor(); + log.Add(Japan() with { IsClaimed = false }); + Assert.Equal(0, log.Tally.Qsos); + Assert.Equal(0, log.Tally.Points); + Assert.Equal(0, log.Tally.TotalMultipliers); + Assert.Equal(0, log.TotalScore); + } + + [Fact] + public void AnUnclaimedContactDoesNotDupeALaterOne() + { + ContestLog log = LogFor(); + log.Add(Japan() with { IsClaimed = false }); + Assert.False(log.IsWorked(Japan(at: Later))); + } + + [Fact] + public void AnUnclaimedContactLeavesItsMultipliersForALaterOne() + { + ContestLog log = LogFor(); + log.Add(Japan() with { IsClaimed = false }); + Verdict later = log.Judge(Japan("JH1ABC", Later)); + Assert.Equal([1, 2], later.NewMultipliers.Select(m => m.Index).ToList()); + } + + /// N1MM searches for dupes and multipliers among the claimed contacts + /// whether or not they are marked as a dupe, so the second contact here + /// gets no multiplier and the multiplier is lost from the score. + [Fact] + public void AContactMarkedAsADupeTakesItsMultipliersOutOfTheScore() + { + ContestLog log = LogFor(); + log.Add(Japan() with { ContactType = "D" }); + Assert.Equal(0, log.Tally.Qsos); + Assert.Equal(0, log.Tally.TotalMultipliers); + Assert.Empty(log.Judge(Japan("JH1ABC", Later)).NewMultipliers); + Assert.True(log.IsWorked(Japan(at: Later))); + } +} diff --git a/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs b/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs index e357b35..d366019 100644 --- a/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs +++ b/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs @@ -73,14 +73,36 @@ public class CabrilloWriterTests Assert.EndsWith("END-OF-LOG:\r\n", log); } + /// N1MM writes a contact the operator left out of the claimed score under + /// `X-QSO:`, which the sponsors read as a contact the entrant is not + /// claiming. It is not dropped from the file. [Fact] - public void ContactsLeftOutOfTheClaimedScoreAreNotSent() + public void ContactsLeftOutOfTheClaimedScoreAreSentAsXQso() { CabrilloWriter writer = new(new CqWorldWide(ModeCategory.Cw), Me); string log = writer.Write( new CabrilloHeader { Contest = "CQ-WW-CW", Callsign = "DL1ABC" }, [Contact() with { IsClaimed = false }]); - Assert.DoesNotContain("QSO:", log); + Assert.Contains("\r\nX-QSO: ", log); + Assert.DoesNotContain("\r\nQSO: ", log); + } + + /// The three contests where a DX entrant sends a serial. N1MM writes the + /// serial of that contact, so a log cannot come out with `001` on every + /// line. ARRL 10M sends it unpadded, the other two pad it to three digits. + [Fact] + public void ADxEntrantSendsTheSerialOfTheContact() + { + Qso qso = Contact(28_025) with { SentNumber = 42 }; + Assert.Contains( + "599 42 JA1XYZ", + new CabrilloWriter(new Arrl10Metre(), Me).QsoLine(qso)); + Assert.Contains( + "599 042 JA1XYZ", + new CabrilloWriter(new UkrainianDx(), Me).QsoLine(qso)); + Assert.Contains( + "599 042 JA1XYZ", + new CabrilloWriter(new YoDxHf(), Me).QsoLine(qso)); } /// The SA 10 file, whose sponsor asks for its own columns: the operator's