From e0f307968e487abc0499f7e508379b3b849ff82d Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 10 Sep 2026 19:38:57 +0000 Subject: [PATCH] Mark a dupe in the contact type column, as N1MM does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N1MM writes a contact type on every contact it logs: `D` for a station already worked, `N` for one the entry may not work at all, and empty for an ordinary contact. Nothing here wrote the column, so a dupe counted as a contact worth no points instead of dropping out of the score, and the two places that read the column did nothing: the QTC pile offered dupes that N1MM leaves out, and the statistics window could not leave dupes out either. The marking is in one place, `ContestLog.ApplyVerdict`, and every contest is marked the same way. What changes between contests is what counts as a dupe and which stations are workable, and both come from the contest, as they do in N1MM's `Contact.IsDupe`. No contest module in N1MM marks differently. Our own callsign is not workable either, which is what N1MM adds on top of the contest rule. Two details follow N1MM as well: QTC traffic keeps an empty column, because its `IsDupe` only marks a row while the program is logging contacts; and a mark that came with the contact stands, because N1MM marks a contact when it logs or edits one and never clears marks in a pass over the log. Editing a contact clears the column so it is worked out again, which is what N1MM does at the same point: an edit can move a contact to a band where it is no longer a dupe. A dupe is now out of the score altogether — contacts, points and multipliers — which is N1MM's `ScoreClass.AddContact`. The QSO count still counts WAE QTC rows as contacts; that is the other half of the scoring audit's item 14. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ --- src/Nonemm.Contests/Contest.cs | 6 ++ src/Nonemm.Contests/ContestLog.cs | 37 +++++++- src/Nonemm.Contests/Rules/AllAsian.cs | 2 +- src/Nonemm.Contests/Rules/OceaniaDx.cs | 2 +- src/Nonemm.Core/Qso.cs | 4 +- src/Nonemm.Session/QsoEditor.cs | 11 ++- .../Nonemm.Contests.Tests/ContactTypeTests.cs | 94 +++++++++++++++++++ tests/Nonemm.Session.Tests/QsoEditorTests.cs | 11 +++ tests/Nonemm.Session.Tests/QtcTrafficTests.cs | 15 +++ 9 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 tests/Nonemm.Contests.Tests/ContactTypeTests.cs diff --git a/src/Nonemm.Contests/Contest.cs b/src/Nonemm.Contests/Contest.cs index 6e40438..3912595 100644 --- a/src/Nonemm.Contests/Contest.cs +++ b/src/Nonemm.Contests/Contest.cs @@ -128,6 +128,12 @@ public interface Contest /// bring no multiplier and cannot be a dupe. bool IsContact(Qso qso) => true; + /// False for a station this entry may not work at all: the wrong continent + /// in WAE, our own callsign, and so on. N1MM marks such a contact `N` in + /// the contact type column and gives it no points. Most contests take + /// every station, so the answer is usually yes. + bool IsWorkable(QsoContext qso) => true; + IReadOnlyList MultipliersFor(QsoContext qso); /// The claimed score. The entry is here because a contest can multiply diff --git a/src/Nonemm.Contests/ContestLog.cs b/src/Nonemm.Contests/ContestLog.cs index 9af2037..5a2fc1e 100644 --- a/src/Nonemm.Contests/ContestLog.cs +++ b/src/Nonemm.Contests/ContestLog.cs @@ -170,8 +170,41 @@ public sealed class ContestLog History = History, }; - private static Qso ApplyVerdict(Qso qso, Verdict verdict) => - ApplyMultiplierFlags(qso, verdict.NewMultipliers) with { Points = verdict.Points }; + private Qso ApplyVerdict(Qso qso, Verdict verdict) => + ApplyMultiplierFlags(qso, verdict.NewMultipliers) with + { + Points = verdict.Points, + ContactType = ContactTypeOf(qso), + }; + + /// N1MM's contact type column, which it writes in `Contact.IsDupe`: `D` + /// for a station already worked, `N` for one this entry may not work at + /// all, and empty for an ordinary contact. Every contest is marked the + /// same way; what changes between them is what counts as a dupe and which + /// stations are workable, and both of those come from the contest here as + /// they do there. + /// + /// Traffic keeps an empty column, which is what N1MM's QTC window writes: + /// its `IsDupe` only marks a row while the program is logging contacts. + private string ContactTypeOf(Qso qso) + { + if (!contest.IsContact(qso)) + { + return ""; + } + // a mark that came with the contact stands: N1MM marks a contact when + // it logs or edits it and never clears the mark in a pass over the + // log, so a row that arrived from another station or from an N1MM + // database keeps what it was given + if (IsWorked(qso) || qso.ContactType.Equals("D", StringComparison.OrdinalIgnoreCase)) + { + return "D"; + } + // N1MM adds our own callsign to whatever the contest says + bool workable = contest.IsWorkable(ContextFor(qso)) + && !qso.Call.Text.Equals(me.Callsign, StringComparison.OrdinalIgnoreCase); + return workable ? "" : "N"; + } private static Qso ApplyMultiplierFlags(Qso qso, IReadOnlyList claimed) => qso with { diff --git a/src/Nonemm.Contests/Rules/AllAsian.cs b/src/Nonemm.Contests/Rules/AllAsian.cs index ba23f6a..680e448 100644 --- a/src/Nonemm.Contests/Rules/AllAsian.cs +++ b/src/Nonemm.Contests/Rules/AllAsian.cs @@ -66,7 +66,7 @@ public sealed class AllAsian : Contest ]); /// One side of every contact has to be in Asia. - private static bool IsWorkable(QsoContext qso) => + public bool IsWorkable(QsoContext qso) => qso.Continent == "AS" || qso.Me.Continent == "AS"; /// 160 metres is worth 3, 80 and 10 metres 2, and every other band 1. diff --git a/src/Nonemm.Contests/Rules/OceaniaDx.cs b/src/Nonemm.Contests/Rules/OceaniaDx.cs index 6ccc299..3cf7479 100644 --- a/src/Nonemm.Contests/Rules/OceaniaDx.cs +++ b/src/Nonemm.Contests/Rules/OceaniaDx.cs @@ -61,7 +61,7 @@ public sealed class OceaniaDx : Contest ]); /// One side of every contact has to be in Oceania. - private static bool IsWorkable(QsoContext qso) => + public bool IsWorkable(QsoContext qso) => qso.Continent == "OC" || qso.Me.Continent == "OC"; private static int PointsOn(Band? band) => band?.Name switch diff --git a/src/Nonemm.Core/Qso.cs b/src/Nonemm.Core/Qso.cs index 040963f..94d2d00 100644 --- a/src/Nonemm.Core/Qso.cs +++ b/src/Nonemm.Core/Qso.cs @@ -96,8 +96,8 @@ public sealed record Qso 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. + /// the claimed contacts that are not marked as a dupe; `ContestLog` marks + /// them the same way it does. public bool IsInScore => IsClaimed && !ContactType.Equals("D", StringComparison.OrdinalIgnoreCase); diff --git a/src/Nonemm.Session/QsoEditor.cs b/src/Nonemm.Session/QsoEditor.cs index 50dcd53..bd3b752 100644 --- a/src/Nonemm.Session/QsoEditor.cs +++ b/src/Nonemm.Session/QsoEditor.cs @@ -67,7 +67,16 @@ public sealed class QsoEditor public QsoEdit Apply(Qso qso, QsoField field, string text) { QsoEdit edit = Applied(qso, field, text); - return edit.IsAccepted ? edit : edit with { Field = field }; + if (!edit.IsAccepted) + { + return edit with { Field = field }; + } + // the contact type is left for the log to work out again: an edit can + // move a contact to a band where it is no longer a dupe. N1MM works + // it out again at the same point + return edit.Result is { } changed + ? edit with { Result = changed with { ContactType = "" } } + : edit; } /// Applies several fields in one go, stopping at the first value the diff --git a/tests/Nonemm.Contests.Tests/ContactTypeTests.cs b/tests/Nonemm.Contests.Tests/ContactTypeTests.cs new file mode 100644 index 0000000..cbe86ae --- /dev/null +++ b/tests/Nonemm.Contests.Tests/ContactTypeTests.cs @@ -0,0 +1,94 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +/// The contact type column N1MM writes in `Contact.IsDupe`: `D` for a station +/// already worked, `N` for one this entry may not work, and empty for an +/// ordinary contact. Every contest is marked the same way; what changes +/// between them is what counts as a dupe and which stations are workable. +public class ContactTypeTests +{ + private static readonly DateTime Later = new(2026, 5, 30, 13, 0, 0, DateTimeKind.Utc); + + private static ContestLog CqWw() => + new(new CqWorldWide(ModeCategory.Cw), TestLog.Germany, TestLog.CountryFile); + + private static Qso Japan(double kilohertz = 14_025, DateTime? at = null) => + TestLog.Contact("JA1XYZ", kilohertz: kilohertz, zone: 25, at: at); + + [Fact] + public void AFirstContactIsNotMarked() => + Assert.Equal("", CqWw().Add(Japan()).ContactType); + + [Fact] + public void TheSecondContactWithAStationIsMarkedAsADupe() + { + ContestLog log = CqWw(); + log.Add(Japan()); + + Assert.Equal("D", log.Add(Japan(at: Later)).ContactType); + } + + /// The dupe rule is the contest's, so a station worked again on another + /// band is a contact rather than a dupe in a contest counted per band. + [Fact] + public void TheSameStationOnAnotherBandIsNotADupe() + { + ContestLog log = CqWw(); + log.Add(Japan()); + + Assert.Equal("", log.Add(Japan(kilohertz: 21_025, at: Later)).ContactType); + } + + /// N1MM leaves a dupe out of the score altogether — points, contacts and + /// multipliers — rather than counting it as a contact worth nothing. + [Fact] + public void ADupeIsOutOfTheScore() + { + ContestLog log = CqWw(); + log.Add(Japan()); + log.Add(Japan(at: Later)); + + Assert.Equal(1, log.Tally.Qsos); + Assert.Equal(3, log.Tally.Points); + Assert.Equal(2, log.Tally.TotalMultipliers); + } + + /// WAE on CW is Europe against the rest of the world, so a European + /// station working Europe may not work it at all. N1MM marks that `N`. + [Fact] + public void AStationTheEntryMayNotWorkIsMarked() + { + ContestLog log = new(new Wae(ModeCategory.Cw), TestLog.Germany, TestLog.CountryFile); + + Assert.Equal("N", log.Add(TestLog.Contact("OM5M", exchange: WaeQtc.Contact)).ContactType); + Assert.Equal("", log.Add(TestLog.Contact("JA1XYZ", exchange: WaeQtc.Contact)).ContactType); + } + + /// N1MM adds our own callsign to whatever the contest says is workable. + [Fact] + public void OurOwnCallsignIsNotWorkable() => + Assert.Equal("N", CqWw().Add(TestLog.Contact("DL1ABC")).ContactType); + + /// QTC traffic is not a contact, and N1MM's QTC window writes an empty + /// column on those rows. + [Fact] + public void TrafficIsNotMarked() + { + ContestLog log = new(new Wae(ModeCategory.Cw), TestLog.Germany, TestLog.CountryFile); + log.Add(TestLog.Contact("JA1XYZ", exchange: WaeQtc.Contact)); + Qso traffic = new WaeQtc( + Callsign.Parse("JA1XYZ"), IsSent: false, 1, 10, "1435", Callsign.Parse("W1AW"), 42) + .ApplyTo(TestLog.Contact("JA1XYZ", at: Later)); + + Assert.Equal("", log.Add(traffic).ContactType); + } + + /// A mark that came with the contact stands, because N1MM never clears one + /// in a pass over the log: a row from another station of the entry, or + /// from a database N1MM wrote, keeps what it was given. + [Fact] + public void AMarkThatCameWithTheContactStands() => + Assert.Equal("D", CqWw().Add(Japan() with { ContactType = "D" }).ContactType); +} diff --git a/tests/Nonemm.Session.Tests/QsoEditorTests.cs b/tests/Nonemm.Session.Tests/QsoEditorTests.cs index 4b0d9b1..48d4ef3 100644 --- a/tests/Nonemm.Session.Tests/QsoEditorTests.cs +++ b/tests/Nonemm.Session.Tests/QsoEditorTests.cs @@ -111,6 +111,17 @@ public class QsoEditorTests Assert.Contains("callsign", edit.Error); } + /// An edit can move a contact to a band where it is no longer a dupe, so + /// the mark comes off and the log works it out again. N1MM works it out + /// again at the same point. + [Fact] + public void EditingAContactClearsTheContactType() + { + QsoEdit edit = CqWw().Apply(Contact() with { ContactType = "D" }, QsoField.Frequency, "21005"); + + Assert.Equal("", edit.Result!.ContactType); + } + [Fact] public void ARefusedEditReturnsNoContact() => Assert.Null(CqWw().Apply(Contact(), QsoField.Zone, "41").Result); diff --git a/tests/Nonemm.Session.Tests/QtcTrafficTests.cs b/tests/Nonemm.Session.Tests/QtcTrafficTests.cs index 4976e9b..b67b3e6 100644 --- a/tests/Nonemm.Session.Tests/QtcTrafficTests.cs +++ b/tests/Nonemm.Session.Tests/QtcTrafficTests.cs @@ -187,6 +187,21 @@ public class QtcTrafficTests Assert.Equal(87, traffic.NextSeries()); } + /// A contact that is a dupe is not worth reporting, and it is what N1MM + /// leaves out of the pile with `Trim(ContactType) = ''`. + [Fact] + public void ADupeContactIsNotReported() + { + (OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital); + Contact(position, "DL1AAA", receivedNumber: 16, sentNumber: 1, minute: 46); + Contact(position, "DL1AAA", receivedNumber: 16, sentNumber: 2, minute: 47); + + IReadOnlyList lines = traffic.ToSend(Callsign.Parse("JA1XYZ")); + + Assert.Equal(["DL1AAA"], lines.Select(l => l.Call.Text).ToList()); + Assert.Equal(1, traffic.Available(Callsign.Parse("JA1XYZ"))); + } + /// Europe on CW and SSB only takes traffic down, so there is no pile of /// contacts to report and the line says how many of the ten are left. [Fact]