Mark a dupe in the contact type column, as N1MM does

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
This commit is contained in:
2026-09-10 19:38:57 +00:00
co-authored by Claude Opus 5
parent 0d9b4a713e
commit e0f307968e
9 changed files with 175 additions and 7 deletions
+6
View File
@@ -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<Multiplier> MultipliersFor(QsoContext qso);
/// The claimed score. The entry is here because a contest can multiply
+35 -2
View File
@@ -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<Multiplier> claimed) => qso with
{
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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);
+10 -1
View File
@@ -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
@@ -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);
}
@@ -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);
@@ -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<WaeQtc> 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]