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:
@@ -128,6 +128,12 @@ public interface Contest
|
|||||||
/// bring no multiplier and cannot be a dupe.
|
/// bring no multiplier and cannot be a dupe.
|
||||||
bool IsContact(Qso qso) => true;
|
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);
|
IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso);
|
||||||
|
|
||||||
/// The claimed score. The entry is here because a contest can multiply
|
/// The claimed score. The entry is here because a contest can multiply
|
||||||
|
|||||||
@@ -170,8 +170,41 @@ public sealed class ContestLog
|
|||||||
History = History,
|
History = History,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static Qso ApplyVerdict(Qso qso, Verdict verdict) =>
|
private Qso ApplyVerdict(Qso qso, Verdict verdict) =>
|
||||||
ApplyMultiplierFlags(qso, verdict.NewMultipliers) with { Points = verdict.Points };
|
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
|
private static Qso ApplyMultiplierFlags(Qso qso, IReadOnlyList<Multiplier> claimed) => qso with
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ public sealed class AllAsian : Contest
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
/// One side of every contact has to be in Asia.
|
/// 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";
|
qso.Continent == "AS" || qso.Me.Continent == "AS";
|
||||||
|
|
||||||
/// 160 metres is worth 3, 80 and 10 metres 2, and every other band 1.
|
/// 160 metres is worth 3, 80 and 10 metres 2, and every other band 1.
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public sealed class OceaniaDx : Contest
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
/// One side of every contact has to be in Oceania.
|
/// 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";
|
qso.Continent == "OC" || qso.Me.Continent == "OC";
|
||||||
|
|
||||||
private static int PointsOn(Band? band) => band?.Name switch
|
private static int PointsOn(Band? band) => band?.Name switch
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ public sealed record Qso
|
|||||||
public bool IsClaimed { get; init; } = true;
|
public bool IsClaimed { get; init; } = true;
|
||||||
|
|
||||||
/// Counted in the score. N1MM sums points, contacts and multipliers over
|
/// 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 claimed contacts that are not marked as a dupe; `ContestLog` marks
|
||||||
/// the `D` contact type, but a log N1MM wrote can carry it.
|
/// them the same way it does.
|
||||||
public bool IsInScore =>
|
public bool IsInScore =>
|
||||||
IsClaimed && !ContactType.Equals("D", StringComparison.OrdinalIgnoreCase);
|
IsClaimed && !ContactType.Equals("D", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,16 @@ public sealed class QsoEditor
|
|||||||
public QsoEdit Apply(Qso qso, QsoField field, string text)
|
public QsoEdit Apply(Qso qso, QsoField field, string text)
|
||||||
{
|
{
|
||||||
QsoEdit edit = Applied(qso, field, 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
|
/// 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);
|
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]
|
[Fact]
|
||||||
public void ARefusedEditReturnsNoContact() =>
|
public void ARefusedEditReturnsNoContact() =>
|
||||||
Assert.Null(CqWw().Apply(Contact(), QsoField.Zone, "41").Result);
|
Assert.Null(CqWw().Apply(Contact(), QsoField.Zone, "41").Result);
|
||||||
|
|||||||
@@ -187,6 +187,21 @@ public class QtcTrafficTests
|
|||||||
Assert.Equal(87, traffic.NextSeries());
|
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
|
/// 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.
|
/// contacts to report and the line says how many of the ten are left.
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user