diff --git a/src/Nonemm.App/Dialogs/ThemeDialog.axaml.cs b/src/Nonemm.App/Dialogs/ThemeDialog.axaml.cs index 26d6ddc..b358ab2 100644 --- a/src/Nonemm.App/Dialogs/ThemeDialog.axaml.cs +++ b/src/Nonemm.App/Dialogs/ThemeDialog.axaml.cs @@ -33,13 +33,15 @@ public sealed partial class ThemeDialog : Window { Theme theme = Picked; 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[] { Swatch(theme.Worth, theme.FieldBackground), Swatch(theme.Multiplier, theme.FieldBackground), Swatch(theme.Multipliers, theme.FieldBackground), Swatch(theme.Dupe, theme.FieldBackground), + Swatch(theme.QtcCallsign, theme.FieldBackground), }; } diff --git a/src/Nonemm.App/Theming/Theme.cs b/src/Nonemm.App/Theming/Theme.cs index 95b787c..18b00ef 100644 --- a/src/Nonemm.App/Theming/Theme.cs +++ b/src/Nonemm.App/Theming/Theme.cs @@ -50,6 +50,10 @@ public sealed record Theme 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. public required Color Cq { get; init; } @@ -91,6 +95,7 @@ public sealed record Theme MultipliersBackground = Color.FromRgb(0x5F, 0xF1, 0x5F), Dupe = Color.FromRgb(0x80, 0x80, 0x80), DupeBackground = Color.FromRgb(0xD4, 0xD4, 0xD4), + QtcCallsign = Color.FromRgb(0x00, 0x00, 0x00), Cq = Color.FromRgb(0xFF, 0x00, 0x00), GoodBackground = Color.FromRgb(0xD4, 0xF5, 0xD4), EmptyBackground = Color.FromRgb(0xFF, 0xE4, 0xE1), @@ -121,6 +126,9 @@ public sealed record Theme MultipliersBackground = Color.FromRgb(0x1B, 0x5E, 0x36), Dupe = Color.FromRgb(0x7F, 0x8C, 0x8D), 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), GoodBackground = Color.FromRgb(0x1E, 0x38, 0x24), EmptyBackground = Color.FromRgb(0x3A, 0x24, 0x24), diff --git a/src/Nonemm.App/Verdicts.cs b/src/Nonemm.App/Verdicts.cs index b5625d2..ebfcfa6 100644 --- a/src/Nonemm.App/Verdicts.cs +++ b/src/Nonemm.App/Verdicts.cs @@ -17,6 +17,10 @@ public static class Verdicts 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; /// 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 /// 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) => verdict is null ? Worth - : verdict.IsDupe ? Dupe + : verdict.IsDupe ? verdict.TrafficLeft > 0 ? Traffic : Dupe : verdict.NewMultipliers.Count > 1 ? NewMultipliers : verdict.NewMultipliers.Count == 1 ? NewMultiplier + : verdict.TrafficLeft > 0 ? NewMultipliers : Worth; /// The same four states as backgrounds, which is what N1MM paints behind a @@ -54,13 +66,16 @@ public static class Verdicts { 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) { - return "dupe"; + return $"dupe{traffic}"; } string points = $"{verdict.Points} {(verdict.Points == 1 ? "point" : "points")}"; return verdict.IsNewMultiplier - ? $"{points} · new {string.Join(", ", verdict.NewMultipliers.Select(m => m.Value))}" - : points; + ? $"{points} · new {string.Join(", ", verdict.NewMultipliers.Select(m => m.Value))}{traffic}" + : $"{points}{traffic}"; } } diff --git a/src/Nonemm.Contests/Contest.cs b/src/Nonemm.Contests/Contest.cs index 3912595..1cb58a6 100644 --- a/src/Nonemm.Contests/Contest.cs +++ b/src/Nonemm.Contests/Contest.cs @@ -128,6 +128,11 @@ public interface Contest /// bring no multiplier and cannot be a dupe. 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 /// 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 diff --git a/src/Nonemm.Contests/ContestLog.cs b/src/Nonemm.Contests/ContestLog.cs index 5a2fc1e..a8e963f 100644 --- a/src/Nonemm.Contests/ContestLog.cs +++ b/src/Nonemm.Contests/ContestLog.cs @@ -13,6 +13,11 @@ public sealed class ContestLog private readonly CountryFile? countries; private readonly List qsos = []; private readonly Dictionary 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 trafficWith = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet claimedMultipliers = new(StringComparer.Ordinal); private readonly Dictionary rows = []; private ScoreTally tally = new(); @@ -56,6 +61,10 @@ public sealed class ContestLog 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. public Verdict Judge(Qso candidate) { @@ -68,9 +77,10 @@ public sealed class ContestLog { return new Verdict(false, contest.PointsFor(context), []); } + int trafficLeft = contest.TrafficLeft(context, TrafficWith(candidate.Call.Text)); if (IsWorked(candidate)) { - return Verdict.Dupe; + return Verdict.Dupe with { TrafficLeft = trafficLeft }; } List newOnes = []; 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 // be set before the points are worked out 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. @@ -234,6 +248,8 @@ public sealed class ContestLog } if (!contest.IsContact(qso)) { + string station = qso.Call.Text.Trim(); + trafficWith[station] = trafficWith.GetValueOrDefault(station) + 1; return; } workedKeys[DupeKey(qso)] = qso.TimestampUtc; @@ -270,6 +286,7 @@ public sealed class ContestLog private void Rebuild() { workedKeys.Clear(); + trafficWith.Clear(); claimedMultipliers.Clear(); rows.Clear(); tally = new ScoreTally(); diff --git a/src/Nonemm.Contests/Rules/Wae.cs b/src/Nonemm.Contests/Rules/Wae.cs index 59fba8d..912fae2 100644 --- a/src/Nonemm.Contests/Rules/Wae.cs +++ b/src/Nonemm.Contests/Rules/Wae.cs @@ -86,6 +86,18 @@ public sealed class Wae : Contest 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 /// Europe it is the other station's country or call area; for a station /// outside it, the European country worked. diff --git a/src/Nonemm.Contests/Verdict.cs b/src/Nonemm.Contests/Verdict.cs index 41ad0bc..e52fba6 100644 --- a/src/Nonemm.Contests/Verdict.cs +++ b/src/Nonemm.Contests/Verdict.cs @@ -3,10 +3,14 @@ namespace Nonemm.Contests; /// 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 /// 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( bool IsDupe, int Points, - IReadOnlyList NewMultipliers) + IReadOnlyList NewMultipliers, + int TrafficLeft = 0) { public static readonly Verdict Dupe = new(true, 0, []); diff --git a/tests/Nonemm.Contests.Tests/WaeTests.cs b/tests/Nonemm.Contests.Tests/WaeTests.cs index fb3b5fe..da4a707 100644 --- a/tests/Nonemm.Contests.Tests/WaeTests.cs +++ b/tests/Nonemm.Contests.Tests/WaeTests.cs @@ -171,6 +171,71 @@ public class WaeTests 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 /// the same in its manual: "the ADIF file will not contain QTCs, only /// QSOs".