diff --git a/docs/unfinished.md b/docs/unfinished.md index 21bb1f1..a6be3c2 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -405,9 +405,18 @@ by hand stays closed until it is opened again, because the next beacon would otherwise open it within ten seconds. The Resynchronize and Rescore tabs work on a period — the contest, since a time, or the last few hours. Options holds what there is something behind: Log QSOs at all Stations, the message trace, and -Run1/Run2. Left out are the transmit interlocks — this program does not block a -transmitter, and a station that claimed it did would be waited on for nothing — -and the two telnet options, which wait on the master relaying cluster spots. +Run1/Run2. Left out are the two telnet options, which wait on the master relaying cluster +spots. + +**Two signals at once.** N1MM blocks the transmitter when the entry is about to +break its own class. This program does not touch the transmitter — a logger that +decides for itself when the operator may not send loses a contact every time it +is wrong — so the entry window puts a red box across its top instead, naming the +other station and the band. `TransmitRules` holds what is allowed: one signal in +a one-transmitter entry, two on different bands in a two-transmitter entry, and +never two on one band whatever the class. A station that has not said which band +it is on counts as somewhere else, because a guess there puts the box in front of +an operator doing nothing wrong. **What the entry agrees on.** Four things have to match across the stations of an entry, and each is now checked rather than assumed: diff --git a/src/Nonemm.App/Windows/EntryWindow.Macros.cs b/src/Nonemm.App/Windows/EntryWindow.Macros.cs index 94496c3..34c8ab2 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Macros.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Macros.cs @@ -185,6 +185,8 @@ public sealed partial class EntryWindow { try { + // what the other operators read in their network status window + sendingWhat = text.Length > 40 ? text[..40] : text; sending = true; Refresh(); await keyer.SendAsync(text); diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml index 2f7b08a..11f3fdf 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml +++ b/src/Nonemm.App/Windows/EntryWindow.axaml @@ -127,6 +127,15 @@ + + + + + others = link.Stations + .Where(s => !s.IsMine && s.IsTransmitting) + .Select(s => new Transmitter(s.ComputerName, s.Band, s.Mode?.Category)) + .ToList(); + return TransmitRules.Violation( + Logging.Log.Entry, + new Transmitter(link.ComputerName, Bands.ForFrequency(Logging.Frequency), Logging.Mode.Category), + others); } /// The countdown N1MM puts in its info window: how long the station still diff --git a/src/Nonemm.Contests/TransmitRules.cs b/src/Nonemm.Contests/TransmitRules.cs new file mode 100644 index 0000000..0375a00 --- /dev/null +++ b/src/Nonemm.Contests/TransmitRules.cs @@ -0,0 +1,96 @@ +using Nonemm.Core; + +namespace Nonemm.Contests; + +/// One station of the entry with its key down, as the others see it. +public sealed record Transmitter(string ComputerName, Band? Band, ModeCategory? Mode); + +/// How many signals a multi-operator entry may have on the air at once, and on +/// which bands. +/// +/// N1MM blocks the transmitter when the entry is about to break these. This +/// program does not touch the transmitter — a logger that decides for itself +/// when the operator may not send is a logger that loses a contact when it is +/// wrong — so it says what is happening and leaves it to the operator. +/// +/// | Entry | What is allowed | +/// |---|---| +/// | one transmitter | one signal on the air | +/// | two transmitters | two signals, on different bands | +/// | anything else | as many as the sponsor allows, one per band | +/// +/// Two signals on one band is wrong in every multi-operator class, which is why +/// it is checked whatever the category says. An entry that is neither one nor +/// two transmitters — multi-unlimited, and a single operator with two radios — +/// is only held to that. +public static class TransmitRules +{ + /// What is wrong with this station transmitting now, or null when nothing + /// is. `others` is every other station of the entry that has its key down. + public static string? Violation( + ContestEntry entry, + Transmitter me, + IReadOnlyList others) + { + if (others.Count == 0) + { + return null; + } + if (others.FirstOrDefault(o => SameBand(o, me) && o.Mode == me.Mode) is { } sameMode) + { + return $"{sameMode.ComputerName} is transmitting on {Where(me)} as well"; + } + if (others.FirstOrDefault(o => SameBand(o, me)) is { } sameBand) + { + return $"{sameBand.ComputerName} is transmitting on {me.Band?.Name} as well"; + } + int allowed = Transmitters(entry); + if (allowed > 0 && others.Count + 1 > allowed) + { + string signals = $"{others.Count + 1} signals"; + string on = string.Join(", ", others.Select(Where).Prepend(Where(me))); + return allowed == 1 + ? $"{signals} on the air — a one-transmitter entry may have one: {on}" + : $"{signals} on the air — a two-transmitter entry may have two: {on}"; + } + return null; + } + + /// How many signals the entry may have at once, or zero when the category + /// does not say. N1MM reads the number off the operator category on + /// Cabrillo 2.0 and off the transmitter category after it, and so does + /// this. + private static int Transmitters(ContestEntry entry) + { + if (!entry.OperatorCategory.StartsWith("MULTI", StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + if (Is(entry, "MULTI-ONE", "ONE")) + { + return 1; + } + return Is(entry, "MULTI-TWO", "TWO") ? 2 : 0; + } + + private static bool Is(ContestEntry entry, string operatorCategory, string transmitterCategory) => + entry.OperatorCategory.Equals(operatorCategory, StringComparison.OrdinalIgnoreCase) + || entry.TransmitterCategory.Equals(transmitterCategory, StringComparison.OrdinalIgnoreCase); + + /// Two stations are on the same band only when both say which band they are + /// on. A station that has not said counts as somewhere else: a guess here + /// puts a red box in front of an operator who is doing nothing wrong. + private static bool SameBand(Transmitter one, Transmitter other) => + one.Band is not null && other.Band is not null && one.Band == other.Band; + + private static string Where(Transmitter station) => + $"{station.Band?.Name ?? "somewhere"} {Named(station.Mode)}".TrimEnd(); + + private static string Named(ModeCategory? mode) => mode switch + { + ModeCategory.Cw => "CW", + ModeCategory.Phone => "phone", + ModeCategory.Digital => "digital", + _ => "", + }; +} diff --git a/tests/Nonemm.Contests.Tests/TransmitRulesTests.cs b/tests/Nonemm.Contests.Tests/TransmitRulesTests.cs new file mode 100644 index 0000000..693e779 --- /dev/null +++ b/tests/Nonemm.Contests.Tests/TransmitRulesTests.cs @@ -0,0 +1,120 @@ +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +/// How many signals a multi-operator entry may have on the air, and what the +/// entry window says when there is one too many. +public class TransmitRulesTests +{ + private static readonly ContestEntry MultiOne = new() + { + OperatorCategory = "MULTI-ONE", + TransmitterCategory = "ONE", + }; + + private static readonly ContestEntry MultiTwo = new() + { + OperatorCategory = "MULTI-TWO", + TransmitterCategory = "TWO", + }; + + private static readonly ContestEntry Unlimited = new() + { + OperatorCategory = "MULTI-UNLIMITED", + TransmitterCategory = "UNLIMITED", + }; + + private static Transmitter On(string name, Band band, ModeCategory mode = ModeCategory.Cw) => + new(name, band, mode); + + [Fact] + public void OneSignalIsAlwaysAllowed() => + Assert.Null(TransmitRules.Violation(MultiOne, On("RUN-PC", Bands.Band20M), [])); + + /// Two signals on one band and mode is wrong in every class. + [Fact] + public void TwoStationsOnOneBandAndModeIsNamed() + { + string? why = TransmitRules.Violation( + Unlimited, + On("RUN-PC", Bands.Band20M), + [On("MULT-PC", Bands.Band20M)]); + + Assert.Equal("MULT-PC is transmitting on 20M CW as well", why); + } + + [Fact] + public void TwoStationsOnOneBandInDifferentModesIsNamed() + { + string? why = TransmitRules.Violation( + Unlimited, + On("RUN-PC", Bands.Band20M), + [On("MULT-PC", Bands.Band20M, ModeCategory.Phone)]); + + Assert.Equal("MULT-PC is transmitting on 20M as well", why); + } + + /// A one-transmitter entry may have one signal, wherever the other station + /// is. + [Fact] + public void ASecondSignalBreaksAOneTransmitterEntry() + { + string? why = TransmitRules.Violation( + MultiOne, + On("RUN-PC", Bands.Band20M), + [On("MULT-PC", Bands.Band40M)]); + + Assert.NotNull(why); + Assert.Contains("one-transmitter entry", why); + Assert.Contains("20M CW", why); + Assert.Contains("40M CW", why); + } + + [Fact] + public void TwoSignalsOnDifferentBandsAreFineForTwoTransmitters() => + Assert.Null(TransmitRules.Violation( + MultiTwo, + On("RUN-PC", Bands.Band20M), + [On("MULT-PC", Bands.Band40M)])); + + [Fact] + public void AThirdSignalBreaksATwoTransmitterEntry() + { + string? why = TransmitRules.Violation( + MultiTwo, + On("RUN-PC", Bands.Band20M), + [On("MULT-PC", Bands.Band40M), On("MULT2-PC", Bands.Band15M)]); + + Assert.NotNull(why); + Assert.Contains("two-transmitter entry", why); + } + + /// A class that does not cap the transmitters is held to the one rule that + /// applies to everybody. + [Fact] + public void AnUnlimitedEntryMayHaveASignalPerBand() => + Assert.Null(TransmitRules.Violation( + Unlimited, + On("RUN-PC", Bands.Band20M), + [On("MULT-PC", Bands.Band40M), On("MULT2-PC", Bands.Band15M)])); + + /// A station that has not said where it is counts as somewhere else. A + /// guess here puts a red box in front of an operator doing nothing wrong. + [Fact] + public void AStationWithNoBandIsNotCountedAsOnThisOne() => + Assert.Null(TransmitRules.Violation( + Unlimited, + On("RUN-PC", Bands.Band20M), + [new Transmitter("MULT-PC", null, null)])); + + /// A single operator with two radios is nobody's multi-operator entry, but + /// two signals on one band is still two signals on one band. + [Fact] + public void ASingleOperatorIsOnlyHeldToTheOneBandRule() + { + ContestEntry single = new() { OperatorCategory = "SINGLE-OP", TransmitterCategory = "ONE" }; + + Assert.Null(TransmitRules.Violation(single, On("RUN-PC", Bands.Band20M), [On("SO2R-PC", Bands.Band40M)])); + Assert.NotNull(TransmitRules.Violation(single, On("RUN-PC", Bands.Band20M), [On("SO2R-PC", Bands.Band20M)])); + } +}