Paint a WAE station by the traffic left with it, as N1MM does

N1MM has four colours for a station in WAE rather than the usual three, and
the manual and the RTTY tutorial both spell them out. Red is a new
multiplier, as everywhere else. Green is a new station with QTC traffic
still to exchange, which takes the more-than-one-multiplier colour because
WAE has no second multiplier to use it. Black is a station already worked
that still has traffic left — worth working for the QTC points alone — and
it is a colour of its own, N1MM's "Color of a QTC callsign in the WAE
contest". Grey is a dupe with nothing left, the same as in other contests.

The verdict carries how much traffic is left with the station, so the
callsign box, the check window, the bandmap and everything else that colours
a station say the same thing. The contest works the number out: ten with a
station on the other side of the contest, none with one on our own
continent, and none once the ten have gone. Every other contest answers
nought. The log counts the QTC rows per station as they are indexed rather
than counting them again for each station judged, because the bandmap judges
every spot it holds on every pass.

The verdict line says it in words as well, so a dupe reads `dupe · 7 QTCs
left` rather than `dupe`.

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 20:03:32 +00:00
co-authored by Claude Opus 5
parent f24cab0ae5
commit 2079bf3f59
8 changed files with 136 additions and 8 deletions
+3 -1
View File
@@ -33,13 +33,15 @@ public sealed partial class ThemeDialog : Window
{ {
Theme theme = Picked; Theme theme = Picked;
AboutText.Text = "What a station is painted in the callsign box, left to right: worth " + AboutText.Text = "What a station is painted in the callsign box, left to right: worth " +
"working, one new multiplier, more than one, a dupe."; "working, one new multiplier, more than one, a dupe, and in WAE a dupe " +
"with QTC traffic still to exchange.";
Swatches.ItemsSource = new[] Swatches.ItemsSource = new[]
{ {
Swatch(theme.Worth, theme.FieldBackground), Swatch(theme.Worth, theme.FieldBackground),
Swatch(theme.Multiplier, theme.FieldBackground), Swatch(theme.Multiplier, theme.FieldBackground),
Swatch(theme.Multipliers, theme.FieldBackground), Swatch(theme.Multipliers, theme.FieldBackground),
Swatch(theme.Dupe, theme.FieldBackground), Swatch(theme.Dupe, theme.FieldBackground),
Swatch(theme.QtcCallsign, theme.FieldBackground),
}; };
} }
+8
View File
@@ -50,6 +50,10 @@ public sealed record Theme
public required Color DupeBackground { get; init; } public required Color DupeBackground { get; init; }
/// A station in WAE that still has QTC traffic to exchange, which N1MM
/// calls "Color of a QTC callsign in the WAE contest" and paints black.
public required Color QtcCallsign { get; init; }
/// The frequency we are calling CQ on. /// The frequency we are calling CQ on.
public required Color Cq { get; init; } public required Color Cq { get; init; }
@@ -91,6 +95,7 @@ public sealed record Theme
MultipliersBackground = Color.FromRgb(0x5F, 0xF1, 0x5F), MultipliersBackground = Color.FromRgb(0x5F, 0xF1, 0x5F),
Dupe = Color.FromRgb(0x80, 0x80, 0x80), Dupe = Color.FromRgb(0x80, 0x80, 0x80),
DupeBackground = Color.FromRgb(0xD4, 0xD4, 0xD4), DupeBackground = Color.FromRgb(0xD4, 0xD4, 0xD4),
QtcCallsign = Color.FromRgb(0x00, 0x00, 0x00),
Cq = Color.FromRgb(0xFF, 0x00, 0x00), Cq = Color.FromRgb(0xFF, 0x00, 0x00),
GoodBackground = Color.FromRgb(0xD4, 0xF5, 0xD4), GoodBackground = Color.FromRgb(0xD4, 0xF5, 0xD4),
EmptyBackground = Color.FromRgb(0xFF, 0xE4, 0xE1), EmptyBackground = Color.FromRgb(0xFF, 0xE4, 0xE1),
@@ -121,6 +126,9 @@ public sealed record Theme
MultipliersBackground = Color.FromRgb(0x1B, 0x5E, 0x36), MultipliersBackground = Color.FromRgb(0x1B, 0x5E, 0x36),
Dupe = Color.FromRgb(0x7F, 0x8C, 0x8D), Dupe = Color.FromRgb(0x7F, 0x8C, 0x8D),
DupeBackground = Color.FromRgb(0x3A, 0x40, 0x40), DupeBackground = Color.FromRgb(0x3A, 0x40, 0x40),
// black on a dark background cannot be read, so the dark theme takes
// the other end of the same idea: a plain, bright callsign
QtcCallsign = Color.FromRgb(0xEC, 0xF0, 0xF1),
Cq = Color.FromRgb(0xE7, 0x4C, 0x3C), Cq = Color.FromRgb(0xE7, 0x4C, 0x3C),
GoodBackground = Color.FromRgb(0x1E, 0x38, 0x24), GoodBackground = Color.FromRgb(0x1E, 0x38, 0x24),
EmptyBackground = Color.FromRgb(0x3A, 0x24, 0x24), EmptyBackground = Color.FromRgb(0x3A, 0x24, 0x24),
+19 -4
View File
@@ -17,6 +17,10 @@ public static class Verdicts
public static IBrush Dupe => Themes.Brush(Themes.Current.Dupe); public static IBrush Dupe => Themes.Brush(Themes.Current.Dupe);
/// A station with QTC traffic still to exchange, which is worth working in
/// WAE even when it is a dupe.
public static IBrush Traffic => Themes.Brush(Themes.Current.QtcCallsign);
public static IBrush Nothing => Dupe; public static IBrush Nothing => Dupe;
/// The frequency we are calling CQ on. /// The frequency we are calling CQ on.
@@ -28,11 +32,19 @@ public static class Verdicts
/// Nothing typed yet is the same colour as a station worth working: that is /// Nothing typed yet is the same colour as a station worth working: that is
/// what N1MM leaves the callsign box. /// what N1MM leaves the callsign box.
///
/// WAE has two states the other contests do not, and N1MM paints them in
/// colours it already has: a new station with QTC traffic still to
/// exchange takes the more-than-one-multiplier colour, green, because WAE
/// has no second multiplier to use it; a dupe with traffic left takes the
/// QTC colour, black, rather than the grey of a dupe worth nothing. A new
/// multiplier still wins over both.
public static IBrush Colour(Verdict? verdict) => public static IBrush Colour(Verdict? verdict) =>
verdict is null ? Worth verdict is null ? Worth
: verdict.IsDupe ? Dupe : verdict.IsDupe ? verdict.TrafficLeft > 0 ? Traffic : Dupe
: verdict.NewMultipliers.Count > 1 ? NewMultipliers : verdict.NewMultipliers.Count > 1 ? NewMultipliers
: verdict.NewMultipliers.Count == 1 ? NewMultiplier : verdict.NewMultipliers.Count == 1 ? NewMultiplier
: verdict.TrafficLeft > 0 ? NewMultipliers
: Worth; : Worth;
/// The same four states as backgrounds, which is what N1MM paints behind a /// The same four states as backgrounds, which is what N1MM paints behind a
@@ -54,13 +66,16 @@ public static class Verdicts
{ {
return ""; return "";
} }
// a dupe in WAE is still worth working while it has traffic left, so
// the line says how much rather than only that the station is worked
string traffic = verdict.TrafficLeft > 0 ? $" · {verdict.TrafficLeft} QTCs left" : "";
if (verdict.IsDupe) if (verdict.IsDupe)
{ {
return "dupe"; return $"dupe{traffic}";
} }
string points = $"{verdict.Points} {(verdict.Points == 1 ? "point" : "points")}"; string points = $"{verdict.Points} {(verdict.Points == 1 ? "point" : "points")}";
return verdict.IsNewMultiplier return verdict.IsNewMultiplier
? $"{points} · new {string.Join(", ", verdict.NewMultipliers.Select(m => m.Value))}" ? $"{points} · new {string.Join(", ", verdict.NewMultipliers.Select(m => m.Value))}{traffic}"
: points; : $"{points}{traffic}";
} }
} }
+5
View File
@@ -128,6 +128,11 @@ 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;
/// How many more QTC lines may be exchanged with this station, given how
/// many have gone already. WAE answers; every other contest has no traffic
/// and answers nought.
int TrafficLeft(QsoContext qso, int exchanged) => 0;
/// False for a station this entry may not work at all: the wrong continent /// 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 /// 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 /// the contact type column and gives it no points. Most contests take
+19 -2
View File
@@ -13,6 +13,11 @@ public sealed class ContestLog
private readonly CountryFile? countries; private readonly CountryFile? countries;
private readonly List<Qso> qsos = []; private readonly List<Qso> qsos = [];
private readonly Dictionary<string, DateTime> workedKeys = new(StringComparer.Ordinal); private readonly Dictionary<string, DateTime> workedKeys = new(StringComparer.Ordinal);
/// How many QTC lines have gone with each station, either way. It is kept
/// here rather than counted when a station is judged, because the bandmap
/// judges every spot it holds on every pass.
private readonly Dictionary<string, int> trafficWith = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> claimedMultipliers = new(StringComparer.Ordinal); private readonly HashSet<string> claimedMultipliers = new(StringComparer.Ordinal);
private readonly Dictionary<ScoreKey, ScoreTally> rows = []; private readonly Dictionary<ScoreKey, ScoreTally> rows = [];
private ScoreTally tally = new(); private ScoreTally tally = new();
@@ -56,6 +61,10 @@ public sealed class ContestLog
public int TotalScore => contest.TotalScore(tally, Entry); public int TotalScore => contest.TotalScore(tally, Entry);
/// How many QTC lines have already gone with this station, either way.
public int TrafficWith(string call) =>
trafficWith.TryGetValue(call.Trim(), out int count) ? count : 0;
/// What logging this contact right now would do, without logging it. /// What logging this contact right now would do, without logging it.
public Verdict Judge(Qso candidate) public Verdict Judge(Qso candidate)
{ {
@@ -68,9 +77,10 @@ public sealed class ContestLog
{ {
return new Verdict(false, contest.PointsFor(context), []); return new Verdict(false, contest.PointsFor(context), []);
} }
int trafficLeft = contest.TrafficLeft(context, TrafficWith(candidate.Call.Text));
if (IsWorked(candidate)) if (IsWorked(candidate))
{ {
return Verdict.Dupe; return Verdict.Dupe with { TrafficLeft = trafficLeft };
} }
List<Multiplier> newOnes = []; List<Multiplier> newOnes = [];
foreach (Multiplier multiplier in contest.MultipliersFor(context)) foreach (Multiplier multiplier in contest.MultipliersFor(context))
@@ -83,7 +93,11 @@ public sealed class ContestLog
// a contest can pay a bonus for a new multiplier, so the flags have to // a contest can pay a bonus for a new multiplier, so the flags have to
// be set before the points are worked out // be set before the points are worked out
QsoContext scored = ContextFor(ApplyMultiplierFlags(candidate, newOnes)); QsoContext scored = ContextFor(ApplyMultiplierFlags(candidate, newOnes));
return new Verdict(false, IsContestMode(candidate) ? contest.PointsFor(scored) : 0, newOnes); return new Verdict(
false,
IsContestMode(candidate) ? contest.PointsFor(scored) : 0,
newOnes,
trafficLeft);
} }
/// Adds the contact and returns it with points and multiplier flags filled in. /// Adds the contact and returns it with points and multiplier flags filled in.
@@ -234,6 +248,8 @@ public sealed class ContestLog
} }
if (!contest.IsContact(qso)) if (!contest.IsContact(qso))
{ {
string station = qso.Call.Text.Trim();
trafficWith[station] = trafficWith.GetValueOrDefault(station) + 1;
return; return;
} }
workedKeys[DupeKey(qso)] = qso.TimestampUtc; workedKeys[DupeKey(qso)] = qso.TimestampUtc;
@@ -270,6 +286,7 @@ public sealed class ContestLog
private void Rebuild() private void Rebuild()
{ {
workedKeys.Clear(); workedKeys.Clear();
trafficWith.Clear();
claimedMultipliers.Clear(); claimedMultipliers.Clear();
rows.Clear(); rows.Clear();
tally = new ScoreTally(); tally = new ScoreTally();
+12
View File
@@ -86,6 +86,18 @@ public sealed class Wae : Contest
public bool IsContact(Qso qso) => !WaeQtc.IsQtc(qso); public bool IsContact(Qso qso) => !WaeQtc.IsQtc(qso);
/// Ten QTC lines with any one station, for the whole contest and whatever
/// the band. Traffic goes between continents only: on CW and SSB that is
/// the same line the contest itself is drawn on, and on RTTY it is any two
/// continents that are not the same.
public int TrafficLeft(QsoContext qso, int exchanged)
{
bool between = mode == ModeCategory.Digital
? qso.Continent.Length > 0 && qso.Continent != qso.Me.Continent
: IsWorkable(qso);
return between ? Math.Max(0, WaeQtc.MostPerStation - exchanged) : 0;
}
/// One multiplier per band, worth more the lower the band. For a station in /// One multiplier per band, worth more the lower the band. For a station in
/// Europe it is the other station's country or call area; for a station /// Europe it is the other station's country or call area; for a station
/// outside it, the European country worked. /// outside it, the European country worked.
+5 -1
View File
@@ -3,10 +3,14 @@ namespace Nonemm.Contests;
/// What the log says about a contact: whether it counts, what it scores, and /// What the log says about a contact: whether it counts, what it scores, and
/// which multipliers it brings in. Every window that colours a station reads /// which multipliers it brings in. Every window that colours a station reads
/// this, so one station cannot look worked in one place and new in another. /// this, so one station cannot look worked in one place and new in another.
/// `TrafficLeft` is how many more QTC lines may be exchanged with the station,
/// which is nought in every contest but WAE. A station with traffic left is
/// worth working even as a dupe, and N1MM paints it in a colour of its own.
public sealed record Verdict( public sealed record Verdict(
bool IsDupe, bool IsDupe,
int Points, int Points,
IReadOnlyList<Multiplier> NewMultipliers) IReadOnlyList<Multiplier> NewMultipliers,
int TrafficLeft = 0)
{ {
public static readonly Verdict Dupe = new(true, 0, []); public static readonly Verdict Dupe = new(true, 0, []);
+65
View File
@@ -171,6 +171,71 @@ public class WaeTests
Assert.Equal(2, log.Tally.TotalMultipliers); Assert.Equal(2, log.Tally.TotalMultipliers);
} }
/// N1MM paints a station by what is left to exchange with it as well as by
/// what it scores, so the verdict carries how much traffic is left: ten
/// with a station on the other side of the contest, none with one on our
/// own, and none once the ten have gone.
[Fact]
public void TheVerdictSaysHowMuchTrafficIsLeft()
{
ContestLog log = LogFor(Cw, TestLog.Germany);
Assert.Equal(10, log.Judge(On("JA1XYZ", 14_025)).TrafficLeft);
Assert.Equal(0, log.Judge(On("OM5M", 14_025)).TrafficLeft);
}
[Fact]
public void TrafficRunsOutAfterTenLines()
{
ContestLog log = LogFor(Cw, TestLog.Germany);
log.Add(On("JA1XYZ", 14_025));
for (int line = 1; line <= 10; line++)
{
log.Add(new WaeQtc(
Callsign.Parse("JA1XYZ"), IsSent: false, 1, 10, "1435", Callsign.Parse("W1AW"), line)
.ApplyTo(On("JA1XYZ", 14_025)));
}
Verdict verdict = log.Judge(On("JA1XYZ", 14_025));
Assert.True(verdict.IsDupe);
Assert.Equal(0, verdict.TrafficLeft);
}
/// A station already worked is still worth working while it has traffic
/// left, which is the state N1MM paints in a colour of its own.
[Fact]
public void ADupeWithTrafficLeftSaysSo()
{
ContestLog log = LogFor(Cw, TestLog.Germany);
log.Add(On("JA1XYZ", 14_025));
Verdict verdict = log.Judge(On("JA1XYZ", 14_025));
Assert.True(verdict.IsDupe);
Assert.Equal(10, verdict.TrafficLeft);
}
/// On RTTY the line is between continents rather than between Europe and
/// the rest, so two European stations still cannot exchange traffic.
[Fact]
public void OnRttyTrafficNeedsTwoContinents()
{
ContestLog log = LogFor(Rtty, TestLog.Germany);
Assert.Equal(10, log.Judge(On("JA1XYZ", 14_080, Modes.Rtty)).TrafficLeft);
Assert.Equal(0, log.Judge(On("OM5M", 14_080, Modes.Rtty)).TrafficLeft);
}
/// Every other contest has no traffic at all.
[Fact]
public void AContestWithoutTrafficLeavesTheNumberAtNought()
{
ContestLog log = new(new CqWorldWide(ModeCategory.Cw), TestLog.Germany, TestLog.CountryFile);
Assert.Equal(0, log.Judge(TestLog.Contact("JA1XYZ", zone: 25)).TrafficLeft);
}
/// ADIF has no QTC record, so the export writes contacts alone. N1MM says /// ADIF has no QTC record, so the export writes contacts alone. N1MM says
/// the same in its manual: "the ADIF file will not contain QTCs, only /// the same in its manual: "the ADIF file will not contain QTCs, only
/// QSOs". /// QSOs".