From bae40bb04e7d4b4727031db69afef65140613f4e Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 9 Sep 2026 20:12:37 +0000 Subject: [PATCH] Show the boxes N1MM shows for each contest The entry window had a fixed set of boxes with a Name and Comment box beside the exchange. N1MM lays out one row of boxes per contest and nothing else, so this does the same: general logging keeps Name and Comment, and a contest gets the boxes it exchanges. Two new members on `Contest` carry the cases where the boxes and the log columns are not the same list: - `FromExchange1` spreads what was typed in one box across the log columns. Where the other station sends a serial or a district code depending on where it is, N1MM gives it one box and sorts the value into the number, section or zone column from there, so the score and the Cabrillo file read it from the same column N1MM does. - `LogFieldsFor` gives the log window its own column list. Sweepstakes takes the whole exchange in one box and logs it in four columns; `SweepstakesExchange` parses `72 A 74 SCV` in any order, reading from the last word back as N1MM does. The sent serial is now marked as not required and space steps over it along with the two report boxes, because nobody types the number going out. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015NZkFxHde1iifAQemFTHxf --- README.md | 35 ++++-- src/Nonemm.App/Windows/EntryWindow.Menu.cs | 3 +- src/Nonemm.App/Windows/EntryWindow.axaml.cs | 99 ++-------------- src/Nonemm.Contests/Contest.cs | 14 +++ src/Nonemm.Contests/ExchangeField.cs | 5 + src/Nonemm.Contests/ExchangeFieldKind.cs | 4 + src/Nonemm.Contests/ExchangeSlot.cs | 2 + src/Nonemm.Contests/QsoExchange.cs | 2 + src/Nonemm.Contests/Rules/AllAsian.cs | 3 +- src/Nonemm.Contests/Rules/Arrl10Metre.cs | 34 ++++-- src/Nonemm.Contests/Rules/ArrlDx.cs | 12 +- src/Nonemm.Contests/Rules/BartgSprint.cs | 6 +- src/Nonemm.Contests/Rules/CqOneSixty.cs | 20 ++-- src/Nonemm.Contests/Rules/CqWorldWide.cs | 12 +- src/Nonemm.Contests/Rules/CqWpx.cs | 6 +- src/Nonemm.Contests/Rules/EuDxContest.cs | 14 ++- .../Rules/FieldDayRegionOne.cs | 4 +- src/Nonemm.Contests/Rules/FrenchRef.cs | 41 +++++-- src/Nonemm.Contests/Rules/GeneralLogging.cs | 3 +- src/Nonemm.Contests/Rules/IaruHf.cs | 5 +- src/Nonemm.Contests/Rules/Iota.cs | 4 +- src/Nonemm.Contests/Rules/MexicoRtty.cs | 28 +++-- .../Rules/NorthAmericanQsoParty.cs | 6 +- src/Nonemm.Contests/Rules/OceaniaDx.cs | 6 +- src/Nonemm.Contests/Rules/OkOmDx.cs | 38 +++--- src/Nonemm.Contests/Rules/RttyRoundup.cs | 29 ++++- .../Rules/RussianDistrictAward.cs | 29 ++++- src/Nonemm.Contests/Rules/RussianDxRtty.cs | 26 ++++- src/Nonemm.Contests/Rules/SartgRtty.cs | 6 +- src/Nonemm.Contests/Rules/Sweepstakes.cs | 30 ++++- .../Rules/SweepstakesExchange.cs | 109 ++++++++++++++++++ src/Nonemm.Contests/Rules/UkrainianDx.cs | 40 +++++-- src/Nonemm.Contests/Rules/Wae.cs | 4 +- src/Nonemm.Contests/Rules/YoDxHf.cs | 36 ++++-- src/Nonemm.Contests/Rules/Yota.cs | 3 +- src/Nonemm.Contests/Udc/UserDefinedContest.cs | 5 +- src/Nonemm.Session/AvailableStations.cs | 3 +- src/Nonemm.Session/EntryFields.cs | 11 +- src/Nonemm.Session/ExchangeSlots.cs | 33 +++++- src/Nonemm.Session/OperatingPosition.cs | 34 +++++- src/Nonemm.Session/QsoEditor.cs | 8 +- .../CqWorldWideRttyTests.cs | 6 +- tests/Nonemm.Contests.Tests/OkOmDxTests.cs | 23 ++-- .../Nonemm.Contests.Tests/SweepstakesTests.cs | 47 ++++++++ .../UserDefinedContestTests.cs | 9 +- .../OperatingPositionTests.cs | 53 ++++++--- tests/Nonemm.Session.Tests/QsoEditorTests.cs | 29 +++++ 47 files changed, 702 insertions(+), 277 deletions(-) create mode 100644 src/Nonemm.Contests/Rules/SweepstakesExchange.cs diff --git a/README.md b/README.md index fa48f14..de3a98e 100644 --- a/README.md +++ b/README.md @@ -209,28 +209,37 @@ in again while you are running, and `EsmSendsCorrectedCall` sends the call again in front of the last message when you have corrected it — copy `SM3AB`, fix it to `SM3ABC`, and the key sends `SM3ABC TU DL1ABC`. -### Name, comment, and the two lights +### The boxes a contest shows, and the two lights -Beside the exchange are a Name box and a Comment box, as N1MM has them. Neither -is part of the exchange, so neither holds up a contact: they fill the log's Name -and Comment columns, and a contest that exchanges a name uses that instead. -Neither is in the space walk, as neither is in N1MM's; tab reaches both. The name is filled from the call history file when there is one, -and what the call history says in its UserText column appears under the entry -boxes. +The entry window shows the boxes N1MM's entry window shows for that contest and +no others. General logging has a Name box and a Comment box; a contest has the +boxes it exchanges. Two of them are for what this station sends — the report and +the serial number — and both are filled in and stepped over, as they are in +N1MM. + +Where the other station sends a serial number or a district code depending on +where it is, there is one box for both, again as in N1MM: what is typed goes in +the exchange column, and the number, section or zone column as well, so the +score and the Cabrillo file read it from the same place N1MM does. Sweepstakes +takes its whole exchange in one box — `72 A 74 SCV`, in any order — and logs it +in four columns. + +What the call history says in its UserText column appears under the entry boxes. ### Space and tab They are not the same key, and N1MM does not treat them as one. Space moves between the callsign box and the exchange boxes and round again, -stepping over the report boxes, because nobody types 599: leaving the callsign -box fills them with the report for the mode, 599 on CW and digital, 59 on phone. +stepping over the report boxes and the sent serial, because nobody types 599 or +the number going out: leaving the callsign box fills the reports in for the +mode, 599 on CW and digital, 59 on phone. It also steps over a box this station is not asked for, such as the state in CQ WW RTTY for a station outside the US and Canada. So in CQ WW, space takes you from the callsign straight to the zone, and space again brings you back. -Tab walks every box in turn — callsign, the exchange including the reports, the -name and the comment — and round again; shift and tab go the other way. N1MM +Tab walks every box in turn — the callsign and the exchange, the reports +included — and round again; shift and tab go the other way. N1MM does not touch the tab key at all, so tab there walks its boxes in the order they are laid out, which is what this does. @@ -693,7 +702,9 @@ them — `Exchange1` says `SQTC` or `RQTC`, the time, callsign and serial number of the contact being reported go in the sent report, received report and sent number columns, and the series such as `9/10` goes in the misc text column. A log written by either program therefore opens in the other with its QTCs -intact, and a QTC row is written to the Cabrillo file as a `QTC:` record. +intact, and a QTC row is written to the Cabrillo file as a `QTC:` record. The +log window shows both columns under N1MM's headers for them, `Series` and +`QTC?`, so a QTC row can be told from a contact at a glance. **Ctrl+Z** opens the QTC window for the station being worked, which is the key N1MM uses. The window is laid out the same way: the header, ten lines of time, diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs index 30a743c..7daae4a 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs @@ -286,7 +286,8 @@ public sealed partial class EntryWindow for (int at = 0; at < Logging.Entry.Exchange.Count; at++) { ExchangeField field = Logging.Entry.Exchange[at]; - if ((field.Kind != ExchangeFieldKind.Number && field.Slot != ExchangeSlot.Exchange1) || + if (field.IsSent || + (field.Kind != ExchangeFieldKind.Number && field.Slot != ExchangeSlot.Exchange1) || !int.TryParse(Logging.Entry[at + 1].Trim(), out int number) || number <= 0) { continue; diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index c06f07e..7d0fde1 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -51,8 +51,6 @@ public sealed partial class EntryWindow : Window /// network status window. private string sendingWhat = ""; private TextBlock? callMark; - private TextBox? nameBox; - private TextBox? commentBox; private readonly DispatcherTimer clock = new() { Interval = TimeSpan.FromSeconds(1) }; private readonly Dictionary openWindows = []; private bool updating; @@ -224,55 +222,11 @@ public sealed partial class EntryWindow : Window ExchangeField field = Logging.Entry.Exchange[at]; AddBox(field.Label, field.Width, at + 1); } - AddNotes(); boxes[0].Focus(); + SyncBoxes(); Refresh(); } - /// N1MM keeps a name and a comment beside the exchange, whatever the - /// contest is. Neither is part of the exchange: they fill the log columns - /// of the same names, and a contest that exchanges a name already has one. - private void AddNotes() - { - nameBox = Logging!.Entry.Exchange.Any(f => f.Slot == ExchangeSlot.Name) - ? null - : AddNote("Name", 8, EntryGrid.ColumnDefinitions.Count); - commentBox = AddNote("Comment", 12, EntryGrid.ColumnDefinitions.Count); - } - - private TextBox AddNote(string label, int width, int index) - { - EntryGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); - TextBlock caption = new() { Text = label }; - caption.Classes.Add("label"); - Grid.SetColumn(caption, index); - Grid.SetRow(caption, 0); - EntryGrid.Children.Add(caption); - - TextBox box = new() - { - Width = (width * 13) + 20, - Margin = new Avalonia.Thickness(0, 0, 6, 0), - HorizontalAlignment = HorizontalAlignment.Left, - }; - box.Classes.Add("entry"); - box.TextChanged += (_, _) => OnNoteChanged(); - Grid.SetColumn(box, index); - Grid.SetRow(box, 1); - EntryGrid.Children.Add(box); - return box; - } - - private void OnNoteChanged() - { - if (updating || Logging is null) - { - return; - } - Logging.OtherName = nameBox?.Text ?? ""; - Logging.Comment = commentBox?.Text ?? ""; - } - private void AddBox(string label, int width, int index) { EntryGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); @@ -398,7 +352,7 @@ public sealed partial class EntryWindow : Window e.Handled = true; session.SwapRadio(); break; - case Key.Tab when FocusedIsEntryBox() || FocusedIsNoteBox(): + case Key.Tab when FocusedIsEntryBox(): e.Handled = true; MoveToNextBox(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift)); break; @@ -648,7 +602,6 @@ public sealed partial class EntryWindow : Window bool filled = TakeFramedCall(); filled |= Logging.FillReports(); filled |= Logging.FillFromHistory(); - filled |= FillNameFromHistory(); if (filled) { SyncBoxes(); @@ -663,26 +616,19 @@ public sealed partial class EntryWindow : Window FocusEntryBox(); } - /// What tab does: every box in turn, reports, name and comment included, - /// and round again. N1MM does not touch the tab key, so tab there walks - /// the boxes in the order they are laid out, and this walks the same ones. + /// What tab does: every box in turn, the reports included, and round + /// again. N1MM does not touch the tab key, so tab there walks the boxes in + /// the order they are laid out, and this walks the same ones. private void MoveToNextBox(bool forward) { if (Logging is null) { return; } - List all = [.. boxes, .. new[] { nameBox, commentBox }.OfType()]; - int at = all.FindIndex(box => box.IsFocused); - int next = ((at < 0 ? 0 : at) + (forward ? 1 : -1) + all.Count) % all.Count; - if (next < boxes.Count) - { - Logging.Entry.FocusOn(next); - FocusEntryBox(); - return; - } - all[next].Focus(); - all[next].CaretIndex = all[next].Text?.Length ?? 0; + int at = boxes.FindIndex(box => box.IsFocused); + int next = ((at < 0 ? 0 : at) + (forward ? 1 : -1) + boxes.Count) % boxes.Count; + Logging.Entry.FocusOn(next); + FocusEntryBox(); } private void FocusEntryBox() @@ -695,27 +641,8 @@ public sealed partial class EntryWindow : Window boxes[Logging.Entry.Focus].CaretIndex = boxes[Logging.Entry.Focus].Text?.Length ?? 0; } - /// The call history knows the names in a lot of contests, and the name box - /// is where N1MM shows it when the contest does not exchange one. - private bool FillNameFromHistory() - { - if (Logging is null || nameBox is null || Logging.OtherName.Trim().Length > 0) - { - return false; - } - string known = Logging.Session.History.Find(Logging.Entry.Call)?.Name ?? ""; - if (known.Length == 0) - { - return false; - } - Logging.OtherName = known; - return true; - } - private bool FocusedIsEntryBox() => boxes.Any(b => b.IsFocused); - private bool FocusedIsNoteBox() => nameBox?.IsFocused == true || commentBox?.IsFocused == true; - /// The buttons N1MM puts under its function keys. Each one does what its /// key does, so there is one place deciding what happens. private void OnStopSending(object? sender, RoutedEventArgs e) @@ -798,14 +725,6 @@ public sealed partial class EntryWindow : Window { boxes[at].Text = Logging.Entry[at]; } - if (nameBox is not null) - { - nameBox.Text = Logging.OtherName; - } - if (commentBox is not null) - { - commentBox.Text = Logging.Comment; - } updating = false; } diff --git a/src/Nonemm.Contests/Contest.cs b/src/Nonemm.Contests/Contest.cs index b0498f9..a9988d7 100644 --- a/src/Nonemm.Contests/Contest.cs +++ b/src/Nonemm.Contests/Contest.cs @@ -29,6 +29,20 @@ public interface Contest /// contests skip nothing. bool SkipsField(ExchangeField field, CountryLookup? their) => false; + /// Spreads what was typed in the one exchange box across the other log + /// columns. A lot of contests are a serial number from one station and a + /// district code from another, and N1MM gives them a single box and sorts + /// the value into the section, number or zone column from there. The + /// contact already holds the typed text in `Exchange1` and the country of + /// the call. Contests with a box per column change nothing. + Qso FromExchange1(Qso qso, string typed) => qso; + + /// The exchange columns of the log window. N1MM keeps a column list per + /// contest that is not the entry box list: Sweepstakes types the whole + /// exchange in one box and logs it in four columns. The columns follow the + /// boxes where a contest does not say otherwise. + IReadOnlyList LogFieldsFor(StationInfo me) => ExchangeFieldsFor(me); + /// Up to three names, in the order the score summary shows them. IReadOnlyList MultiplierNames { get; } diff --git a/src/Nonemm.Contests/ExchangeField.cs b/src/Nonemm.Contests/ExchangeField.cs index 9c81f64..6e00d49 100644 --- a/src/Nonemm.Contests/ExchangeField.cs +++ b/src/Nonemm.Contests/ExchangeField.cs @@ -10,6 +10,10 @@ public sealed record ExchangeField( /// How wide the box needs to be, in characters. public int Width { get; init; } = DefaultWidth(Kind); + /// True for a box holding what this station sends. N1MM shows those boxes, + /// fills them in and steps over them, so nothing here waits for one. + public bool IsSent => Slot is ExchangeSlot.SentReport or ExchangeSlot.SentNumber; + private static int DefaultWidth(ExchangeFieldKind kind) => kind switch { ExchangeFieldKind.Report => 3, @@ -22,6 +26,7 @@ public sealed record ExchangeField( ExchangeFieldKind.Precedence => 1, ExchangeFieldKind.Check => 2, ExchangeFieldKind.Power => 4, + ExchangeFieldKind.SweepstakesExchange => 16, _ => 8, }; } diff --git a/src/Nonemm.Contests/ExchangeFieldKind.cs b/src/Nonemm.Contests/ExchangeFieldKind.cs index 4a816cc..4aceb79 100644 --- a/src/Nonemm.Contests/ExchangeFieldKind.cs +++ b/src/Nonemm.Contests/ExchangeFieldKind.cs @@ -15,4 +15,8 @@ public enum ExchangeFieldKind Power, Precedence, Check, + + /// The whole Sweepstakes exchange in one box: number, precedence, check + /// and section, typed in any order. N1MM has one box for it too. + SweepstakesExchange, } diff --git a/src/Nonemm.Contests/ExchangeSlot.cs b/src/Nonemm.Contests/ExchangeSlot.cs index c65126d..062778d 100644 --- a/src/Nonemm.Contests/ExchangeSlot.cs +++ b/src/Nonemm.Contests/ExchangeSlot.cs @@ -4,6 +4,8 @@ namespace Nonemm.Contests; /// columns so an exchange lands in the same place N1MM would put it. public enum ExchangeSlot { + SentReport, + SentNumber, ReceivedReport, SerialNumber, Zone, diff --git a/src/Nonemm.Contests/QsoExchange.cs b/src/Nonemm.Contests/QsoExchange.cs index c2bae3d..c6cfd42 100644 --- a/src/Nonemm.Contests/QsoExchange.cs +++ b/src/Nonemm.Contests/QsoExchange.cs @@ -10,6 +10,8 @@ public static class QsoExchange /// box it holds nothing for. public static string ValueOf(Qso qso, ExchangeSlot slot) => slot switch { + ExchangeSlot.SentReport => qso.SentReport, + ExchangeSlot.SentNumber => Digits(qso.SentNumber), ExchangeSlot.ReceivedReport => qso.ReceivedReport, ExchangeSlot.SerialNumber => Digits(qso.ReceivedNumber), ExchangeSlot.Zone => Digits(qso.Zone), diff --git a/src/Nonemm.Contests/Rules/AllAsian.cs b/src/Nonemm.Contests/Rules/AllAsian.cs index 62977e7..ba23f6a 100644 --- a/src/Nonemm.Contests/Rules/AllAsian.cs +++ b/src/Nonemm.Contests/Rules/AllAsian.cs @@ -20,7 +20,8 @@ public sealed class AllAsian : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), new ExchangeField("Age", ExchangeSlot.Exchange1, ExchangeFieldKind.Number) { Width = 3 }, ]; diff --git a/src/Nonemm.Contests/Rules/Arrl10Metre.cs b/src/Nonemm.Contests/Rules/Arrl10Metre.cs index c951eca..975079e 100644 --- a/src/Nonemm.Contests/Rules/Arrl10Metre.cs +++ b/src/Nonemm.Contests/Rules/Arrl10Metre.cs @@ -17,19 +17,29 @@ public sealed class Arrl10Metre : Contest public string CabrilloName => "ARRL-10"; - public IReadOnlyList ExchangeFieldsFor(StationInfo me) => - [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("S/P", ExchangeSlot.Section, ExchangeFieldKind.UsStateOrCanadianProvince), - new ExchangeField("Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Number), - ]; + /// One box takes the state, province or serial number the other station + /// sends, as N1MM has it. The sent serial box is there only where this + /// station sends a number rather than a state. + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => SendsState(me.CountryPrefix) + ? + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 }, + ] + : + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("SentNR", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 }, + ]; - public bool SkipsField(ExchangeField field, CountryLookup? their) => field.Slot switch - { - ExchangeSlot.Section => their is not null && !SendsState(their.Entity.PrimaryPrefix), - ExchangeSlot.Exchange1 => their is not null && SendsState(their.Entity.PrimaryPrefix), - _ => false, - }; + /// A number is a serial and anything else is a state or province. + public Qso FromExchange1(Qso qso, string typed) => + int.TryParse(typed, out int number) + ? qso with { ReceivedNumber = number, Section = "" } + : qso with { ReceivedNumber = 0, Section = typed.Trim().ToUpperInvariant() }; public IReadOnlyList MultiplierNames => ["States", "Countries"]; diff --git a/src/Nonemm.Contests/Rules/ArrlDx.cs b/src/Nonemm.Contests/Rules/ArrlDx.cs index 8bc0115..9fd4e1c 100644 --- a/src/Nonemm.Contests/Rules/ArrlDx.cs +++ b/src/Nonemm.Contests/Rules/ArrlDx.cs @@ -21,12 +21,16 @@ public sealed class ArrlDx : Contest public string CabrilloName => mode == ModeCategory.Cw ? "ARRL-DX-CW" : "ARRL-DX-SSB"; + /// A W or VE operator copies the power the DX station sends, which goes in + /// the power column; a DX operator copies a state or province, which goes + /// in the section column. Both are where N1MM keeps them. public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), IsNorthAmericanHome(me) - ? new ExchangeField("Pwr", ExchangeSlot.Section, ExchangeFieldKind.Power) - : new ExchangeField("S/P", ExchangeSlot.Section, ExchangeFieldKind.UsStateOrCanadianProvince), + ? new ExchangeField("Pwr", ExchangeSlot.Power, ExchangeFieldKind.Power) + : new ExchangeField("State", ExchangeSlot.Section, ExchangeFieldKind.UsStateOrCanadianProvince), ]; public IReadOnlyList MultiplierNames => ["Mults"]; @@ -74,7 +78,7 @@ public sealed class ArrlDx : Contest [ new CabrilloField(qso.Call.Text, 13), new CabrilloField(qso.ReceivedReport, 3), - new CabrilloField(qso.Section, 6), + new CabrilloField(IsNorthAmericanHome(me) ? qso.Power : qso.Section, 6), ]); private static bool IsNorthAmericanHome(StationInfo me) => IsNorthAmerican(me.CountryPrefix); diff --git a/src/Nonemm.Contests/Rules/BartgSprint.cs b/src/Nonemm.Contests/Rules/BartgSprint.cs index d31ad4f..2606964 100644 --- a/src/Nonemm.Contests/Rules/BartgSprint.cs +++ b/src/Nonemm.Contests/Rules/BartgSprint.cs @@ -25,8 +25,10 @@ public sealed class BartgSprint : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("SentNr", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("RcvNr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), new ExchangeField("Time", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 4 }, ]; diff --git a/src/Nonemm.Contests/Rules/CqOneSixty.cs b/src/Nonemm.Contests/Rules/CqOneSixty.cs index f981c1f..e13cfab 100644 --- a/src/Nonemm.Contests/Rules/CqOneSixty.cs +++ b/src/Nonemm.Contests/Rules/CqOneSixty.cs @@ -24,19 +24,21 @@ public sealed class CqOneSixty : Contest public string CabrilloName => mode == ModeCategory.Cw ? "CQ-160-CW" : "CQ-160-SSB"; + /// One box takes the state, province or CQ zone the other station sends, + /// as N1MM has it. public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("S/P", ExchangeSlot.Section, ExchangeFieldKind.UsStateOrCanadianProvince), - new ExchangeField("Zone", ExchangeSlot.Exchange1, ExchangeFieldKind.CqZone), + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 }, ]; - public bool SkipsField(ExchangeField field, CountryLookup? their) => field.Slot switch - { - ExchangeSlot.Section => their is not null && !IsUsOrCanadian(their.Entity.PrimaryPrefix), - ExchangeSlot.Exchange1 => their is not null && IsUsOrCanadian(their.Entity.PrimaryPrefix), - _ => false, - }; + /// A number is a CQ zone and stays in the exchange column; anything else + /// is a state or province and goes in the section column as well. + public Qso FromExchange1(Qso qso, string typed) => + int.TryParse(typed, out _) + ? qso with { Section = "" } + : qso with { Section = typed.Trim().ToUpperInvariant() }; public IReadOnlyList MultiplierNames => ["States", "Countries"]; diff --git a/src/Nonemm.Contests/Rules/CqWorldWide.cs b/src/Nonemm.Contests/Rules/CqWorldWide.cs index f5694f0..067d58d 100644 --- a/src/Nonemm.Contests/Rules/CqWorldWide.cs +++ b/src/Nonemm.Contests/Rules/CqWorldWide.cs @@ -39,14 +39,16 @@ public sealed class CqWorldWide : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => IsRtty ? [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("Zone", ExchangeSlot.Zone, ExchangeFieldKind.CqZone), - new ExchangeField("S/P", ExchangeSlot.Section, ExchangeFieldKind.UsStateOrCanadianProvince, IsRequired: false), + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("CQ-Zone", ExchangeSlot.Zone, ExchangeFieldKind.CqZone), + new ExchangeField("State/RAC", ExchangeSlot.Section, ExchangeFieldKind.UsStateOrCanadianProvince, IsRequired: false), ] : [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("Zone", ExchangeSlot.Zone, ExchangeFieldKind.CqZone), + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("CQ-Zone", ExchangeSlot.Zone, ExchangeFieldKind.CqZone), ]; /// On RTTY the exchange carries a state or province, but only from the US diff --git a/src/Nonemm.Contests/Rules/CqWpx.cs b/src/Nonemm.Contests/Rules/CqWpx.cs index 23a9e5b..5fb8b5a 100644 --- a/src/Nonemm.Contests/Rules/CqWpx.cs +++ b/src/Nonemm.Contests/Rules/CqWpx.cs @@ -33,8 +33,10 @@ public sealed class CqWpx : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("SntNR", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("RcvNR", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), ]; public IReadOnlyList MultiplierNames => ["Prefixes"]; diff --git a/src/Nonemm.Contests/Rules/EuDxContest.cs b/src/Nonemm.Contests/Rules/EuDxContest.cs index 8f3e30f..39f0a0d 100644 --- a/src/Nonemm.Contests/Rules/EuDxContest.cs +++ b/src/Nonemm.Contests/Rules/EuDxContest.cs @@ -31,10 +31,18 @@ public sealed class EuDxContest : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("Code", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 5 }, + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 5 }, ]; + /// A station in the union sends a district code, which goes in the section + /// column; everyone else sends a CQ zone, which goes in the zone column. + public Qso FromExchange1(Qso qso, string typed) => + IsUnion(qso.CountryPrefix) + ? qso with { Section = typed.Trim().ToUpperInvariant() } + : qso with { Zone = int.TryParse(typed, out int zone) ? zone : 0 }; + public IReadOnlyList MultiplierNames => ["Codes", "Countries"]; public DupeScope DupeScope => DupeScope.PerBandAndMode; @@ -90,7 +98,7 @@ public sealed class EuDxContest : Contest [ new CabrilloField(qso.Call.Text, 13), new CabrilloField(qso.ReceivedReport, 3), - new CabrilloField(qso.Section, 5), + new CabrilloField(qso.Section.Length > 0 ? qso.Section : qso.Exchange1, 5), ]); private static bool IsUnion(string countryPrefix) => Union.Contains(countryPrefix); diff --git a/src/Nonemm.Contests/Rules/FieldDayRegionOne.cs b/src/Nonemm.Contests/Rules/FieldDayRegionOne.cs index 3064d62..cba27ae 100644 --- a/src/Nonemm.Contests/Rules/FieldDayRegionOne.cs +++ b/src/Nonemm.Contests/Rules/FieldDayRegionOne.cs @@ -15,7 +15,9 @@ public sealed class FieldDayRegionOne : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("SentNr", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), ]; diff --git a/src/Nonemm.Contests/Rules/FrenchRef.cs b/src/Nonemm.Contests/Rules/FrenchRef.cs index 28cd4c1..4eb0fac 100644 --- a/src/Nonemm.Contests/Rules/FrenchRef.cs +++ b/src/Nonemm.Contests/Rules/FrenchRef.cs @@ -27,19 +27,36 @@ public sealed class FrenchRef : Contest public string CabrilloName => mode == ModeCategory.Cw ? "REF-CW" : "REF-SSB"; - public IReadOnlyList ExchangeFieldsFor(StationInfo me) => - [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("Dept", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 3 }, - new ExchangeField("Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Number), - ]; + /// A French operator works both sides and gets one box for whichever the + /// other station sends; everyone else works French stations alone, so the + /// box is the department and this station sends a serial number. + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => IsFrench(me.CountryPrefix) + ? + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 3 }, + ] + : + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("SentNr", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Department", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 3 }, + ]; - public bool SkipsField(ExchangeField field, CountryLookup? their) => field.Slot switch - { - ExchangeSlot.Section => their is not null && their.Entity.PrimaryPrefix != "F", - ExchangeSlot.Exchange1 => their is not null && their.Entity.PrimaryPrefix == "F", - _ => false, - }; + public Qso FromExchange1(Qso qso, string typed) => + qso.CountryPrefix == "F" + ? qso with { Section = typed.Trim().ToUpperInvariant() } + : qso with { ReceivedNumber = int.TryParse(typed, out int number) ? number : 0 }; + + /// N1MM's log for this contest keeps what the one box was sorted into in a + /// column of its own beside the exchange. + public IReadOnlyList LogFieldsFor(StationInfo me) => + [ + .. ExchangeFieldsFor(me), + new ExchangeField("Sect", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 5 }, + ]; public IReadOnlyList MultiplierNames => ["Mults"]; diff --git a/src/Nonemm.Contests/Rules/GeneralLogging.cs b/src/Nonemm.Contests/Rules/GeneralLogging.cs index e17e8bb..f87e4a7 100644 --- a/src/Nonemm.Contests/Rules/GeneralLogging.cs +++ b/src/Nonemm.Contests/Rules/GeneralLogging.cs @@ -14,7 +14,8 @@ public sealed class GeneralLogging : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), new ExchangeField("Name", ExchangeSlot.Name, ExchangeFieldKind.Text, IsRequired: false) { Width = 10 }, new ExchangeField("Comment", ExchangeSlot.Comment, ExchangeFieldKind.Text, IsRequired: false) { Width = 20 }, ]; diff --git a/src/Nonemm.Contests/Rules/IaruHf.cs b/src/Nonemm.Contests/Rules/IaruHf.cs index 5bc1588..61c5aec 100644 --- a/src/Nonemm.Contests/Rules/IaruHf.cs +++ b/src/Nonemm.Contests/Rules/IaruHf.cs @@ -17,8 +17,9 @@ public sealed class IaruHf : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("Zone/HQ", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 6 }, + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("ITU-Zone", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 6 }, ]; public IReadOnlyList MultiplierNames => ["Zones", "HQ"]; diff --git a/src/Nonemm.Contests/Rules/Iota.cs b/src/Nonemm.Contests/Rules/Iota.cs index 474b050..5c7793d 100644 --- a/src/Nonemm.Contests/Rules/Iota.cs +++ b/src/Nonemm.Contests/Rules/Iota.cs @@ -19,7 +19,9 @@ public sealed class Iota : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("SentNr", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), new ExchangeField("IOTA", ExchangeSlot.Section, ExchangeFieldKind.Text, IsRequired: false) { diff --git a/src/Nonemm.Contests/Rules/MexicoRtty.cs b/src/Nonemm.Contests/Rules/MexicoRtty.cs index 5fc3421..90864ad 100644 --- a/src/Nonemm.Contests/Rules/MexicoRtty.cs +++ b/src/Nonemm.Contests/Rules/MexicoRtty.cs @@ -15,18 +15,26 @@ public sealed class MexicoRtty : Contest public string CabrilloName => "XE-RTTY"; - public IReadOnlyList ExchangeFieldsFor(StationInfo me) => - [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("State", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 3 }, - new ExchangeField("Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Number), - ]; + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => IsMexican(me.CountryPrefix) + ? + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 3 }, + ] + : + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("SentNR", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 3 }, + ]; - public bool SkipsField(ExchangeField field, CountryLookup? their) => field.Slot switch + /// A Mexican station sends a state, which goes in the section column; the + /// serial everyone else sends stays in the exchange column. + public Qso FromExchange1(Qso qso, string typed) => qso with { - ExchangeSlot.Section => their is not null && !IsMexican(their.Entity.PrimaryPrefix), - ExchangeSlot.Exchange1 => their is not null && IsMexican(their.Entity.PrimaryPrefix), - _ => false, + Section = IsMexican(qso.CountryPrefix) ? typed.Trim().ToUpperInvariant() : "", }; public IReadOnlyList MultiplierNames => ["Countries", "States"]; diff --git a/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs b/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs index 5859da9..3773121 100644 --- a/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs +++ b/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs @@ -25,7 +25,7 @@ public sealed class NorthAmericanQsoParty : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ new ExchangeField("Name", ExchangeSlot.Name, ExchangeFieldKind.Text) { Width = 10 }, - new ExchangeField("S/P/C", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 }, + new ExchangeField("State", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 6 }, ]; public IReadOnlyList MultiplierNames => ["Mults"]; @@ -44,7 +44,7 @@ public sealed class NorthAmericanQsoParty : Contest public IReadOnlyList MultipliersFor(QsoContext qso) { string band = qso.Band?.Name ?? ""; - string exchange = qso.Qso.Exchange1.Trim().ToUpperInvariant(); + string exchange = qso.Qso.Section.Trim().ToUpperInvariant(); if (StatesAndProvinces.IsStateOrProvince(exchange)) { return [new Multiplier(1, exchange, band)]; @@ -66,7 +66,7 @@ public sealed class NorthAmericanQsoParty : Contest [ new CabrilloField(qso.Call.Text, 13), new CabrilloField(qso.Name, 10), - new CabrilloField(qso.Exchange1, 6), + new CabrilloField(qso.Section, 6), ]); private string ModeLabel() => mode switch diff --git a/src/Nonemm.Contests/Rules/OceaniaDx.cs b/src/Nonemm.Contests/Rules/OceaniaDx.cs index 98465f8..6ccc299 100644 --- a/src/Nonemm.Contests/Rules/OceaniaDx.cs +++ b/src/Nonemm.Contests/Rules/OceaniaDx.cs @@ -20,8 +20,10 @@ public sealed class OceaniaDx : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("SntNR", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("RcvNR", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), ]; public IReadOnlyList MultiplierNames => ["Prefixes"]; diff --git a/src/Nonemm.Contests/Rules/OkOmDx.cs b/src/Nonemm.Contests/Rules/OkOmDx.cs index c1d8da3..d1fe323 100644 --- a/src/Nonemm.Contests/Rules/OkOmDx.cs +++ b/src/Nonemm.Contests/Rules/OkOmDx.cs @@ -22,22 +22,30 @@ public sealed class OkOmDx : Contest public string CabrilloName => "OK-OM-DX"; - public IReadOnlyList ExchangeFieldsFor(StationInfo me) => - [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("County", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 4 }, - new ExchangeField("Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Number), - ]; + /// An OK or OM operator works everybody, so the one box takes a county or + /// a serial number; everyone else works OK and OM stations alone and sends + /// a serial number of their own. + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => IsOkOm(me.CountryPrefix) + ? + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 4 }, + ] + : + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("SntNR", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 4 }, + ]; - /// An OK or OM station sends a county and no serial; everyone else the - /// other way round, so one of the two boxes is stepped over. - public bool SkipsField(ExchangeField field, CountryLookup? their) => - field.Slot switch - { - ExchangeSlot.Section => their is not null && !IsOkOm(their.Entity.PrimaryPrefix), - ExchangeSlot.Exchange1 => their is not null && IsOkOm(their.Entity.PrimaryPrefix), - _ => false, - }; + /// A county goes in the section column as well, which is where the + /// multiplier is read from; a serial number stays in the exchange column. + public Qso FromExchange1(Qso qso, string typed) => qso with + { + Section = IsOkOm(qso.CountryPrefix) ? typed.Trim().ToUpperInvariant() : "", + }; public IReadOnlyList MultiplierNames => ["Mults"]; diff --git a/src/Nonemm.Contests/Rules/RttyRoundup.cs b/src/Nonemm.Contests/Rules/RttyRoundup.cs index 01ae5eb..c50e466 100644 --- a/src/Nonemm.Contests/Rules/RttyRoundup.cs +++ b/src/Nonemm.Contests/Rules/RttyRoundup.cs @@ -13,11 +13,30 @@ public sealed class RttyRoundup : Contest public string CabrilloName => "ARRL-RTTY"; - public IReadOnlyList ExchangeFieldsFor(StationInfo me) => - [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("S/P/Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 }, - ]; + /// A North American station sends a state or province and no serial; + /// everyone else sends a serial number, and N1MM shows the sent serial box + /// only for them. + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => me.State.Length > 0 + ? + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 }, + ] + : + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("SentNR", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 }, + ]; + + /// A number is a serial; anything else is a state or province, which goes + /// in the section column as well. + public Qso FromExchange1(Qso qso, string typed) => + int.TryParse(typed, out int number) + ? qso with { ReceivedNumber = number, Section = "" } + : qso with { ReceivedNumber = 0, Section = typed.Trim().ToUpperInvariant() }; public IReadOnlyList MultiplierNames => ["Mults"]; diff --git a/src/Nonemm.Contests/Rules/RussianDistrictAward.cs b/src/Nonemm.Contests/Rules/RussianDistrictAward.cs index ca6e594..0d26566 100644 --- a/src/Nonemm.Contests/Rules/RussianDistrictAward.cs +++ b/src/Nonemm.Contests/Rules/RussianDistrictAward.cs @@ -13,11 +13,27 @@ public sealed class RussianDistrictAward : Contest public string CabrilloName => "RDAC"; - public IReadOnlyList ExchangeFieldsFor(StationInfo me) => - [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("RDA", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 5 }, - ]; + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => IsRussian(me.CountryPrefix) + ? + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 5 }, + ] + : + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("SentNr", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 5 }, + ]; + + /// A district goes in the section column, where the multiplier is read + /// from; everyone else sends a serial number. + public Qso FromExchange1(Qso qso, string typed) => + IsRussian(qso.CountryPrefix) + ? qso with { Section = typed.Trim().ToUpperInvariant() } + : qso with { ReceivedNumber = int.TryParse(typed, out int number) ? number : 0 }; /// N1MM names a country multiplier for this contest and never counts one, /// and the score summary shows both, so the districts stay in the second @@ -59,4 +75,7 @@ public sealed class RussianDistrictAward : Contest ]); private static string District(Qso qso) => qso.Section.Trim().ToUpperInvariant(); + + private static bool IsRussian(string countryPrefix) => + countryPrefix is "UA" or "UA2" or "UA9"; } diff --git a/src/Nonemm.Contests/Rules/RussianDxRtty.cs b/src/Nonemm.Contests/Rules/RussianDxRtty.cs index bbb717d..0dd28e1 100644 --- a/src/Nonemm.Contests/Rules/RussianDxRtty.cs +++ b/src/Nonemm.Contests/Rules/RussianDxRtty.cs @@ -15,11 +15,27 @@ public sealed class RussianDxRtty : Contest public string CabrilloName => "RADIO-WW-RTTY"; - public IReadOnlyList ExchangeFieldsFor(StationInfo me) => - [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("Oblast/Zone", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 3 }, - ]; + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => IsRussian(me.CountryPrefix) + ? + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 3 }, + ] + : + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("SentNr", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 3 }, + ]; + + /// A Russian station sends an oblast, which goes in the section column + /// where the multiplier is read from; everyone else sends a CQ zone. + public Qso FromExchange1(Qso qso, string typed) => qso with + { + Section = typed.Trim().ToUpperInvariant(), + }; public IReadOnlyList MultiplierNames => ["Countries", "Oblasts"]; diff --git a/src/Nonemm.Contests/Rules/SartgRtty.cs b/src/Nonemm.Contests/Rules/SartgRtty.cs index 0b7bfd8..7d5ef9a 100644 --- a/src/Nonemm.Contests/Rules/SartgRtty.cs +++ b/src/Nonemm.Contests/Rules/SartgRtty.cs @@ -15,8 +15,10 @@ public sealed class SartgRtty : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("SntNR", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("RcvNr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), ]; public IReadOnlyList MultiplierNames => ["Countries", "Areas"]; diff --git a/src/Nonemm.Contests/Rules/Sweepstakes.cs b/src/Nonemm.Contests/Rules/Sweepstakes.cs index 4429126..b1dc065 100644 --- a/src/Nonemm.Contests/Rules/Sweepstakes.cs +++ b/src/Nonemm.Contests/Rules/Sweepstakes.cs @@ -17,12 +17,36 @@ public sealed class Sweepstakes : Contest public string CabrilloName => mode == ModeCategory.Cw ? "ARRL-SS-CW" : "ARRL-SS-SSB"; + /// The whole exchange goes in one box, as it does in N1MM: the number, + /// precedence, check and section are read back out of what was typed. public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), + new ExchangeField("Snt Nr", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField( + "Nr Prec CK Section", + ExchangeSlot.Exchange1, + ExchangeFieldKind.SweepstakesExchange), + ]; + + public Qso FromExchange1(Qso qso, string typed) + { + SweepstakesExchange exchange = SweepstakesExchange.Parse(typed); + return qso with + { + ReceivedNumber = exchange.Number, + Precedence = exchange.Precedence, + Check = exchange.Check, + Section = exchange.Section, + }; + } + + /// The log keeps the four parts in four columns, as N1MM's does. + public IReadOnlyList LogFieldsFor(StationInfo me) => + [ + new ExchangeField("NR", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), new ExchangeField("Prec", ExchangeSlot.Precedence, ExchangeFieldKind.Precedence), - new ExchangeField("Ck", ExchangeSlot.Check, ExchangeFieldKind.Check), - new ExchangeField("Sec", ExchangeSlot.Section, ExchangeFieldKind.ArrlSection), + new ExchangeField("CK", ExchangeSlot.Check, ExchangeFieldKind.Check), + new ExchangeField("Sect", ExchangeSlot.Section, ExchangeFieldKind.ArrlSection), ]; public IReadOnlyList MultiplierNames => ["Sections"]; diff --git a/src/Nonemm.Contests/Rules/SweepstakesExchange.cs b/src/Nonemm.Contests/Rules/SweepstakesExchange.cs new file mode 100644 index 0000000..e22ffa7 --- /dev/null +++ b/src/Nonemm.Contests/Rules/SweepstakesExchange.cs @@ -0,0 +1,109 @@ +using Nonemm.Contests.Multipliers; + +namespace Nonemm.Contests.Rules; + +/// The four parts of a Sweepstakes exchange, read out of the one box the +/// operator types them into: `72 A 74 SCV`, or `72A 74SCV`, or any other order. +/// N1MM reads the same box the same way, from the last word back, so a part +/// typed again replaces nothing that was already found. +public sealed record SweepstakesExchange(int Number, string Precedence, int Check, string Section) +{ + private const int HighestNumber = 10000; + + private const int HighestCheck = 99; + + private static readonly IReadOnlySet Precedences = + new HashSet { "A", "B", "Q", "U", "M", "S" }; + + public static SweepstakesExchange Parse(string typed) + { + string rest = typed.Trim().ToUpperInvariant(); + string number = ""; + string check = ""; + string section = ""; + string precedence = ""; + bool foundSection = false; + bool foundNumber = false; + while (rest.Trim().Length > 0) + { + string word = LastWord(rest); + rest = AllButLastWord(rest); + string before = LastWord(rest); + // "72 A" and "74 SCV" are one part typed with a space in it + if ((word.Length == 1 && char.IsAsciiLetter(word[0]) && IsNumber(before)) + || (word.Length is >= 2 and < 4 && word.All(char.IsAsciiLetter) && IsNumber(before))) + { + word = before + word; + rest = AllButLastWord(rest); + } + if (word.Length > 1 && !IsNumber(word[^1..]) && IsNumber(word[..^1])) + { + if (!foundNumber) + { + number = word[..^1]; + foundNumber = true; + } + precedence = precedence.Length == 0 ? PrecedenceFrom(word[^1..]) : precedence; + } + else if (word.Length > 3 && !IsNumber(word) && IsNumber(word[..2]) && word[2..].All(char.IsAsciiLetter)) + { + check = check.Length == 0 ? word[..2] : check; + (section, foundSection) = SectionFrom(word[2..], section, foundSection); + } + else if (word.Length > 2 && !IsNumber(word) && IsNumber(word[..1]) && word[1..].All(char.IsAsciiLetter)) + { + check = check.Length == 0 ? word[..1] : check; + (section, foundSection) = SectionFrom(word[1..], section, foundSection); + } + else if (IsNumber(word) && ((word.Length == 1 && word != "0") || word.Length > 2)) + { + number = foundNumber ? number : word; + foundNumber = true; + } + else if (IsNumber(word) && check.Length > 0 && !foundNumber) + { + number = number.Length == 0 ? word : number; + } + else if (IsNumber(word)) + { + check = check.Length == 0 ? word : check; + } + else if (word.Length > 1) + { + (section, foundSection) = SectionFrom(word, section, foundSection); + } + else if (precedence.Length == 0) + { + precedence = PrecedenceFrom(word); + } + } + return new SweepstakesExchange( + Math.Min(Value(number), HighestNumber), + precedence, + Math.Min(Value(check), HighestCheck), + section); + } + + private static (string Section, bool Found) SectionFrom(string word, string section, bool found) => + found || !ArrlSections.IsSection(word) ? (section, found) : (word, true); + + private static string PrecedenceFrom(string word) => Precedences.Contains(word) ? word : ""; + + private static string LastWord(string text) + { + string trimmed = text.Trim(); + int space = trimmed.LastIndexOf(' '); + return space < 0 ? trimmed : trimmed[(space + 1)..]; + } + + private static string AllButLastWord(string text) + { + string trimmed = text.Trim(); + int space = trimmed.LastIndexOf(' '); + return space < 0 ? "" : trimmed[..space]; + } + + private static bool IsNumber(string text) => text.Length > 0 && text.All(char.IsAsciiDigit); + + private static int Value(string text) => int.TryParse(text, out int number) ? number : 0; +} diff --git a/src/Nonemm.Contests/Rules/UkrainianDx.cs b/src/Nonemm.Contests/Rules/UkrainianDx.cs index a4d083d..a0ab885 100644 --- a/src/Nonemm.Contests/Rules/UkrainianDx.cs +++ b/src/Nonemm.Contests/Rules/UkrainianDx.cs @@ -15,19 +15,35 @@ public sealed class UkrainianDx : Contest public string CabrilloName => "UKRAINIAN-DX"; - public IReadOnlyList ExchangeFieldsFor(StationInfo me) => - [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("Oblast", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 3 }, - new ExchangeField("Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Number), - ]; + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => IsUkrainian(me.CountryPrefix) + ? + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 3 }, + ] + : + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("SntNR", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 3 }, + ]; - public bool SkipsField(ExchangeField field, CountryLookup? their) => field.Slot switch - { - ExchangeSlot.Section => their is not null && !IsUkrainian(their.Entity.PrimaryPrefix), - ExchangeSlot.Exchange1 => their is not null && IsUkrainian(their.Entity.PrimaryPrefix), - _ => false, - }; + /// A number is the serial everyone outside Ukraine sends; anything else is + /// an oblast, which goes in the section column as well. + public Qso FromExchange1(Qso qso, string typed) => + int.TryParse(typed, out int number) + ? qso with { ReceivedNumber = number, Section = "" } + : qso with { ReceivedNumber = 0, Section = typed.Trim().ToUpperInvariant() }; + + /// N1MM's log for this contest keeps what the one box was sorted into in a + /// column of its own beside the exchange. + public IReadOnlyList LogFieldsFor(StationInfo me) => + [ + .. ExchangeFieldsFor(me), + new ExchangeField("Sect", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 5 }, + ]; public IReadOnlyList MultiplierNames => ["Countries", "Oblasts"]; diff --git a/src/Nonemm.Contests/Rules/Wae.cs b/src/Nonemm.Contests/Rules/Wae.cs index 440c556..dd9f842 100644 --- a/src/Nonemm.Contests/Rules/Wae.cs +++ b/src/Nonemm.Contests/Rules/Wae.cs @@ -49,7 +49,9 @@ public sealed class Wae : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("SentNR", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), ]; diff --git a/src/Nonemm.Contests/Rules/YoDxHf.cs b/src/Nonemm.Contests/Rules/YoDxHf.cs index 2f459dd..cf83e33 100644 --- a/src/Nonemm.Contests/Rules/YoDxHf.cs +++ b/src/Nonemm.Contests/Rules/YoDxHf.cs @@ -15,20 +15,36 @@ public sealed class YoDxHf : Contest public string CabrilloName => "YO-DX-HF"; - public IReadOnlyList ExchangeFieldsFor(StationInfo me) => - [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), - new ExchangeField("County", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 3 }, - new ExchangeField("Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Number), - ]; + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => me.CountryPrefix == "YO" + ? + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 3 }, + ] + : + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Nr", ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Exch", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 3 }, + ]; - public bool SkipsField(ExchangeField field, CountryLookup? their) => field.Slot switch + /// A YO station sends a county, which goes in the section column where the + /// multiplier is read from; everyone else sends a serial number. + public Qso FromExchange1(Qso qso, string typed) => qso with { - ExchangeSlot.Section => their is not null && their.Entity.PrimaryPrefix != "YO", - ExchangeSlot.Exchange1 => their is not null && their.Entity.PrimaryPrefix == "YO", - _ => false, + Section = qso.CountryPrefix == "YO" ? typed.Trim().ToUpperInvariant() : "", }; + /// N1MM's log for this contest keeps what the one box was sorted into in a + /// column of its own beside the exchange. + public IReadOnlyList LogFieldsFor(StationInfo me) => + [ + .. ExchangeFieldsFor(me), + new ExchangeField("Sect", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 5 }, + ]; + public IReadOnlyList MultiplierNames => ["Countries", "Counties"]; public DupeScope DupeScope => DupeScope.PerBandAndMode; diff --git a/src/Nonemm.Contests/Rules/Yota.cs b/src/Nonemm.Contests/Rules/Yota.cs index db5140c..1135712 100644 --- a/src/Nonemm.Contests/Rules/Yota.cs +++ b/src/Nonemm.Contests/Rules/Yota.cs @@ -18,7 +18,8 @@ public sealed class Yota : Contest public IReadOnlyList ExchangeFieldsFor(StationInfo me) => [ - new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), new ExchangeField("Age", ExchangeSlot.Exchange1, ExchangeFieldKind.Number) { Width = 3 }, ]; diff --git a/src/Nonemm.Contests/Udc/UserDefinedContest.cs b/src/Nonemm.Contests/Udc/UserDefinedContest.cs index c94894c..87c76ad 100644 --- a/src/Nonemm.Contests/Udc/UserDefinedContest.cs +++ b/src/Nonemm.Contests/Udc/UserDefinedContest.cs @@ -281,10 +281,11 @@ public sealed class UserDefinedContest : Contest /// The names are N1MM's `LogItem.LogItemNameType` members, which is what a /// published `.udc` file holds. `SectText` is not one of them but files in - /// the wild use it. The sent-side boxes — `SNTText`, `SntNrText` — are not - /// exchange boxes here and fall through. + /// the wild use it. private static ExchangeField? BoxToField(string box, string label) => box.ToUpperInvariant() switch { + "SNTTEXT" => new ExchangeField(label, ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + "SNTNRTEXT" => new ExchangeField(label, ExchangeSlot.SentNumber, ExchangeFieldKind.Number, IsRequired: false), "RCVTEXT" => new ExchangeField(label, ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), "RCVNRTEXT" => new ExchangeField(label, ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), "EXCHANGE1TEXT" => new ExchangeField(label, ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 }, diff --git a/src/Nonemm.Session/AvailableStations.cs b/src/Nonemm.Session/AvailableStations.cs index 9c95b1e..3601418 100644 --- a/src/Nonemm.Session/AvailableStations.cs +++ b/src/Nonemm.Session/AvailableStations.cs @@ -75,6 +75,7 @@ public sealed class AvailableStations return ExchangeSlots.ApplyHistory( CountryFields.Apply(qso, position.Session.Countries), position.Session.History, - position.Entry.Exchange); + position.Contest, + position.Me); } } diff --git a/src/Nonemm.Session/EntryFields.cs b/src/Nonemm.Session/EntryFields.cs index 79fbb39..a6f3fdd 100644 --- a/src/Nonemm.Session/EntryFields.cs +++ b/src/Nonemm.Session/EntryFields.cs @@ -65,9 +65,9 @@ public sealed class EntryFields public void FocusOn(int field) => Focus = Math.Clamp(field, 0, FieldCount - 1); /// What space and tab do: move to the next box the operator types in, and - /// round to the callsign again from the last one. The report boxes are - /// stepped over, because nobody types 599 — N1MM steps over them too, and - /// fills them in instead. + /// round to the callsign again from the last one. The report boxes and the + /// sent serial are stepped over, because nobody types 599 or the number + /// they are sending — N1MM steps over the same three and fills them in. public void Advance() => Focus = Next(1); public void Retreat() => Focus = Next(-1); @@ -78,7 +78,7 @@ public sealed class EntryFields for (int step = 0; step < FieldCount; step++) { at = (at + by + FieldCount) % FieldCount; - if (at == 0 || Exchange[at - 1].Kind != ExchangeFieldKind.Report) + if (at == 0 || !IsFilledIn(Exchange[at - 1])) { return at; } @@ -86,6 +86,9 @@ public sealed class EntryFields return Focus; } + private static bool IsFilledIn(ExchangeField field) => + field.IsSent || field.Kind == ExchangeFieldKind.Report; + /// True when everything the contest asks for has been typed. public bool IsComplete => Call.Trim().Length > 0 && diff --git a/src/Nonemm.Session/ExchangeSlots.cs b/src/Nonemm.Session/ExchangeSlots.cs index c8a7beb..2f4a517 100644 --- a/src/Nonemm.Session/ExchangeSlots.cs +++ b/src/Nonemm.Session/ExchangeSlots.cs @@ -10,6 +10,9 @@ public static class ExchangeSlots { public static Qso Apply(Qso qso, ExchangeSlot slot, string value) => slot switch { + // an empty sent box leaves what the contest filled in + ExchangeSlot.SentReport => value.Length == 0 ? qso : qso with { SentReport = value }, + ExchangeSlot.SentNumber => Number(value) == 0 ? qso : qso with { SentNumber = Number(value) }, ExchangeSlot.ReceivedReport => qso with { ReceivedReport = value }, ExchangeSlot.SerialNumber => qso with { ReceivedNumber = Number(value) }, ExchangeSlot.Zone => qso with { Zone = Number(value) }, @@ -28,7 +31,12 @@ public static class ExchangeSlots /// What the call history file says this station sends in one exchange box, /// or an empty string when the file holds nothing for it. - public static string ValueFrom(CallHistoryEntry known, ExchangeField field) => field.Slot switch + public static string ValueFrom(CallHistoryEntry known, ExchangeField field) => + field.Kind == ExchangeFieldKind.SweepstakesExchange + ? SweepstakesText(known) + : FromSlot(known, field); + + private static string FromSlot(CallHistoryEntry known, ExchangeField field) => field.Slot switch { ExchangeSlot.Name => known.Name, // a file for a section contest often carries the state instead @@ -45,25 +53,38 @@ public static class ExchangeSlots _ => "", }; + /// What the file holds for a Sweepstakes station, put together the way + /// N1MM puts it: one box takes the whole exchange, and a file carries the + /// check and the section of it. The number and the precedence are left for + /// the operator to copy. + private static string SweepstakesText(CallHistoryEntry known) + { + string section = known.Section.Length > 0 ? known.Section : known.State; + return section.Length == 0 ? "" : $"{known.Check:00}{section}"; + } + /// Fills the exchange the call history file published for this station into /// a contact. Used where nobody has copied the exchange yet: a spotted /// station the operator has not worked. - public static Qso ApplyHistory(Qso qso, CallHistory history, IReadOnlyList exchange) + public static Qso ApplyHistory(Qso qso, CallHistory history, Contest contest, StationInfo me) { CallHistoryEntry? known = history.Find(qso.Call.Text); if (known is null) { return qso; } - foreach (ExchangeField field in exchange) + string exchange1 = ""; + foreach (ExchangeField field in contest.ExchangeFieldsFor(me)) { string value = ValueFrom(known, field); - if (value.Length > 0) + if (value.Length == 0) { - qso = Apply(qso, field.Slot, value); + continue; } + qso = Apply(qso, field.Slot, value); + exchange1 = field.Slot == ExchangeSlot.Exchange1 ? value : exchange1; } - return qso; + return exchange1.Length > 0 ? contest.FromExchange1(qso, exchange1) : qso; } private static string Digits(int value) => value > 0 ? value.ToString() : ""; diff --git a/src/Nonemm.Session/OperatingPosition.cs b/src/Nonemm.Session/OperatingPosition.cs index da60951..79158db 100644 --- a/src/Nonemm.Session/OperatingPosition.cs +++ b/src/Nonemm.Session/OperatingPosition.cs @@ -16,6 +16,11 @@ public sealed class OperatingPosition Session = session; RadioNumber = radioNumber; Entry = new EntryFields(session.Contest.ExchangeFieldsFor(session.Me)); + FillSentExchange(); + // N1MM fills the sent serial in again on every entry window when a + // contact is logged, so the other radio is not left showing the number + // that has just gone + Session.Logged += (_, _) => FillSentExchange(); } public ContestSession Session { get; } @@ -38,8 +43,9 @@ public sealed class OperatingPosition public bool IsRunning { get; set; } /// The other operator's name and a note about the contact. Neither is part - /// of any exchange — N1MM keeps both beside the exchange boxes and writes - /// them into the log — so neither counts towards a complete exchange. + /// of any exchange: the name is what a contact being quick edited already + /// held, and the note is what Ctrl+N asks for. Neither counts towards a + /// complete exchange. public string OtherName { get; set; } = ""; public string Comment { get; set; } = ""; @@ -62,8 +68,13 @@ public sealed class OperatingPosition public void Tune(Frequency frequency, Mode? mode = null, Frequency? transmitFrequency = null) { + bool modeChanged = mode is not null && mode.Category != Mode.Category; Frequency = frequency; Mode = mode ?? Mode; + if (modeChanged) + { + FillSentExchange(); + } TransmitFrequency = transmitFrequency ?? TransmitFrequency; Session.NotifyChanged(); } @@ -209,6 +220,7 @@ public sealed class OperatingPosition StampSentTime(); Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim()))); Entry.Clear(); + FillSentExchange(); OtherName = ""; Comment = ""; SentTime = ""; @@ -228,6 +240,19 @@ public sealed class OperatingPosition } } + /// Puts what this station sends into the boxes that show it: the report + /// and the serial number. N1MM keeps both filled in and steps over them, + /// so the number going out is on the screen without being typed. + public void FillSentExchange() + { + foreach (ExchangeField field in Entry.Exchange.Where(f => f.IsSent)) + { + Entry.Set( + field.Slot, + field.Slot == ExchangeSlot.SentReport ? DefaultReport() : SentNumber.ToString()); + } + } + /// Puts the report the operator would send anyway into the report boxes /// that are empty: 599 on CW and digital modes, 59 on phone. N1MM does the /// same when the callsign box is left, which is why neither program stops @@ -406,6 +431,7 @@ public sealed class OperatingPosition Editing = null; held = null; Entry.Clear(); + FillSentExchange(); OtherName = ""; Comment = ""; SentTime = ""; @@ -458,6 +484,8 @@ public sealed class OperatingPosition { qso = ExchangeSlots.Apply(qso, field.Slot, Entry.ValueOf(field.Slot).Trim()); } - return qso; + return Entry.Exchange.Any(f => f.Slot == ExchangeSlot.Exchange1) + ? Contest.FromExchange1(qso, Entry.ValueOf(ExchangeSlot.Exchange1).Trim()) + : qso; } } diff --git a/src/Nonemm.Session/QsoEditor.cs b/src/Nonemm.Session/QsoEditor.cs index 4771607..95a0aa3 100644 --- a/src/Nonemm.Session/QsoEditor.cs +++ b/src/Nonemm.Session/QsoEditor.cs @@ -237,7 +237,7 @@ public sealed class QsoEditor { columns.Add(new QsoColumn("SntNr", QsoField.SentNumber, 5)); } - foreach (ExchangeField field in contest.ExchangeFieldsFor(me)) + foreach (ExchangeField field in contest.LogFieldsFor(me).Where(f => !f.IsSent)) { QsoField mapped = FieldFor(field.Slot); kinds[mapped] = field.Kind; @@ -245,8 +245,10 @@ public sealed class QsoEditor } if (contest.HasQtcTraffic) { - // N1MM's header for the misc column in WAE, where it holds the - // series a QTC row belongs to + // N1MM's two headers for WAE: the exchange column says whether the + // row is a contact or QTC traffic — `QSO`, `SQTC` or `RQTC` — and + // the misc column holds the series a QTC row belongs to + columns.Add(new QsoColumn("Series", QsoField.Exchange1, 6)); columns.Add(new QsoColumn("QTC?", QsoField.MiscText, 6)); } columns.Add(new QsoColumn("Op", QsoField.Operator, 8)); diff --git a/tests/Nonemm.Contests.Tests/CqWorldWideRttyTests.cs b/tests/Nonemm.Contests.Tests/CqWorldWideRttyTests.cs index 6676019..992814a 100644 --- a/tests/Nonemm.Contests.Tests/CqWorldWideRttyTests.cs +++ b/tests/Nonemm.Contests.Tests/CqWorldWideRttyTests.cs @@ -86,9 +86,9 @@ public class CqWorldWideRttyTests { IReadOnlyList fields = Rtty.ExchangeFieldsFor(TestLog.Germany); - Assert.Equal(3, fields.Count); - Assert.Equal(ExchangeSlot.Section, fields[2].Slot); - Assert.False(fields[2].IsRequired); + Assert.Equal(4, fields.Count); + Assert.Equal(ExchangeSlot.Section, fields[3].Slot); + Assert.False(fields[3].IsRequired); } /// The Canadian areas this contest counts are its own list, which splits diff --git a/tests/Nonemm.Contests.Tests/OkOmDxTests.cs b/tests/Nonemm.Contests.Tests/OkOmDxTests.cs index eee0225..7b37218 100644 --- a/tests/Nonemm.Contests.Tests/OkOmDxTests.cs +++ b/tests/Nonemm.Contests.Tests/OkOmDxTests.cs @@ -61,17 +61,24 @@ public class OkOmDxTests /// The entry window walks the county box for an OK or OM station and the /// serial box for everyone else. [Fact] - public void OnlyOneOfTheTwoBoxesIsWalked() + public void TheOneExchangeBoxHoldsACountyOrASerialNumber() { IReadOnlyList fields = Cw.ExchangeFieldsFor(TestLog.Slovakia); - CountryLookup? okom = TestLog.CountryFile.Find("OM3XYZ"); - CountryLookup? dx = TestLog.CountryFile.Find("IK2XYZ"); Assert.Equal( - [ExchangeSlot.ReceivedReport, ExchangeSlot.Exchange1], - fields.Where(f => !Cw.SkipsField(f, dx)).Select(f => f.Slot).ToList()); - Assert.Equal( - [ExchangeSlot.ReceivedReport, ExchangeSlot.Section], - fields.Where(f => !Cw.SkipsField(f, okom)).Select(f => f.Slot).ToList()); + [ExchangeSlot.SentReport, ExchangeSlot.ReceivedReport, ExchangeSlot.Exchange1], + fields.Select(f => f.Slot).ToList()); + } + + /// A county goes in the section column as well, where the multiplier is + /// read from; a serial number from anywhere else does not. + [Fact] + public void ACountyReachesTheSectionColumn() + { + Qso okom = TestLog.Contact("OM3XYZ") with { CountryPrefix = "OM" }; + Qso dx = TestLog.Contact("IK2XYZ") with { CountryPrefix = "I" }; + + Assert.Equal("BAA", Cw.FromExchange1(okom with { Exchange1 = "BAA" }, "BAA").Section); + Assert.Equal("", Cw.FromExchange1(dx with { Exchange1 = "123" }, "123").Section); } } diff --git a/tests/Nonemm.Contests.Tests/SweepstakesTests.cs b/tests/Nonemm.Contests.Tests/SweepstakesTests.cs index 0611f5f..111d3b5 100644 --- a/tests/Nonemm.Contests.Tests/SweepstakesTests.cs +++ b/tests/Nonemm.Contests.Tests/SweepstakesTests.cs @@ -32,4 +32,51 @@ public class SweepstakesTests [Fact] public void SomethingThatIsNotASectionCountsForNothing() => Assert.Empty(Log().Judge(TestLog.Contact("K1ABC", section: "ZZ")).NewMultipliers); + + /// The whole exchange goes in one box, in any order, and comes back out in + /// four columns. These are the shapes N1MM's box takes. + [Theory] + [InlineData("72 A 74 SCV")] + [InlineData("72A 74SCV")] + [InlineData("72 A 74SCV")] + public void TheOneBoxIsReadBackAsFourFields(string typed) + { + SweepstakesExchange exchange = SweepstakesExchange.Parse(typed); + + Assert.Equal(72, exchange.Number); + Assert.Equal("A", exchange.Precedence); + Assert.Equal(74, exchange.Check); + Assert.Equal("SCV", exchange.Section); + } + + [Fact] + public void TheOneBoxFillsTheContact() + { + Qso logged = new Sweepstakes(ModeCategory.Cw) + .FromExchange1(TestLog.Contact("K1ABC"), "72A 74SCV"); + + Assert.Equal(72, logged.ReceivedNumber); + Assert.Equal("A", logged.Precedence); + Assert.Equal(74, logged.Check); + Assert.Equal("SCV", logged.Section); + } + + /// The exchange is one box and four log columns, as it is in N1MM. + [Fact] + public void TheLogKeepsTheFourPartsApart() + { + Sweepstakes contest = new(ModeCategory.Cw); + + Assert.Equal( + [ExchangeSlot.SentNumber, ExchangeSlot.Exchange1], + contest.ExchangeFieldsFor(TestLog.UnitedStates).Select(f => f.Slot).ToList()); + Assert.Equal( + [ + ExchangeSlot.SerialNumber, + ExchangeSlot.Precedence, + ExchangeSlot.Check, + ExchangeSlot.Section, + ], + contest.LogFieldsFor(TestLog.UnitedStates).Select(f => f.Slot).ToList()); + } } diff --git a/tests/Nonemm.Contests.Tests/UserDefinedContestTests.cs b/tests/Nonemm.Contests.Tests/UserDefinedContestTests.cs index bf2c92f..a62dddd 100644 --- a/tests/Nonemm.Contests.Tests/UserDefinedContestTests.cs +++ b/tests/Nonemm.Contests.Tests/UserDefinedContestTests.cs @@ -93,7 +93,7 @@ public class UserDefinedContestTests /// The sent-side boxes are in the same setting and are not exchange boxes. [Fact] - public void TheSentBoxesAreLeftOutOfTheExchange() + public void TheSentBoxesAreBoxesOfTheirOwn() { UserDefinedContest contest = new(UdcFile.Parse(""" [Contest] @@ -103,7 +103,12 @@ public class UserDefinedContestTests """)); Assert.Equal( - [ExchangeSlot.ReceivedReport, ExchangeSlot.SerialNumber], + [ + ExchangeSlot.SentReport, + ExchangeSlot.SentNumber, + ExchangeSlot.ReceivedReport, + ExchangeSlot.SerialNumber, + ], contest.ExchangeFieldsFor(TestLog.Germany).Select(f => f.Slot).ToList()); } } diff --git a/tests/Nonemm.Session.Tests/OperatingPositionTests.cs b/tests/Nonemm.Session.Tests/OperatingPositionTests.cs index 0644c51..e50a583 100644 --- a/tests/Nonemm.Session.Tests/OperatingPositionTests.cs +++ b/tests/Nonemm.Session.Tests/OperatingPositionTests.cs @@ -50,6 +50,34 @@ public class OperatingPositionTests session.Entry.Set(ExchangeSlot.Zone, zone); } + /// N1MM shows what this station sends beside what it copies, keeps both + /// filled in and steps over them. + [Fact] + public void TheSentBoxesAreFilledInAndSteppedOver() + { + OperatingPosition session = Session(new CqWpx(ModeCategory.Cw)); + + Assert.Equal("599", session.Entry.ValueOf(ExchangeSlot.SentReport)); + Assert.Equal("1", session.Entry.ValueOf(ExchangeSlot.SentNumber)); + + // the boxes are the call, the two reports, the sent serial and the + // serial being copied + session.Entry.Advance(); + Assert.Equal(4, session.Entry.Focus); + } + + [Fact] + public void TheSentSerialGoesUpWhenAContactIsLogged() + { + OperatingPosition session = Session(new CqWpx(ModeCategory.Cw)); + session.Entry.Call = "JA1XYZ"; + session.Entry.Set(ExchangeSlot.ReceivedReport, "599"); + session.Entry.Set(ExchangeSlot.SerialNumber, "12"); + + Assert.Equal(1, session.LogContact().SentNumber); + Assert.Equal("2", session.Entry.ValueOf(ExchangeSlot.SentNumber)); + } + /// The BARTG contest sends the time of the exchange, and logs the time it /// sent rather than the time the contact was logged. [Fact] @@ -152,12 +180,12 @@ public class OperatingPositionTests [Fact] public void SpaceStepsOverTheReportAndComesBackToTheCall() { - // CQ WW's boxes are the call, the report and the zone + // CQ WW's boxes are the call, the two reports and the zone OperatingPosition session = Session(); Assert.Equal(0, session.Entry.Focus); session.Entry.Advance(); - Assert.Equal(2, session.Entry.Focus); + Assert.Equal(3, session.Entry.Focus); session.Entry.Advance(); Assert.Equal(0, session.Entry.Focus); } @@ -166,7 +194,7 @@ public class OperatingPositionTests public void GoingBackStepsOverTheReportToo() { OperatingPosition session = Session(); - session.Entry.FocusOn(2); + session.Entry.FocusOn(3); session.Entry.Retreat(); @@ -388,8 +416,7 @@ public class OperatingPositionTests session.Entry.Call = "W3LPL"; Assert.True(session.FillFromHistory()); - Assert.Equal("MDC", session.Entry.ValueOf(ExchangeSlot.Section)); - Assert.Equal("56", session.Entry.ValueOf(ExchangeSlot.Check)); + Assert.Equal("56MDC", session.Entry.ValueOf(ExchangeSlot.Exchange1)); } /// What the other station actually sends beats what was published, so a box @@ -401,11 +428,11 @@ public class OperatingPositionTests new Sweepstakes(ModeCategory.Cw), "!!Order!!,Call,Sect\nW3LPL,MDC"); session.Entry.Call = "W3LPL"; - session.Entry.Set(ExchangeSlot.Section, "EPA"); + session.Entry.Set(ExchangeSlot.Exchange1, "72A 74EPA"); session.FillFromHistory(); - Assert.Equal("EPA", session.Entry.ValueOf(ExchangeSlot.Section)); + Assert.Equal("72A 74EPA", session.Entry.ValueOf(ExchangeSlot.Exchange1)); } [Fact] @@ -417,7 +444,7 @@ public class OperatingPositionTests session.Entry.Call = "JA1XYZ"; Assert.False(session.FillFromHistory()); - Assert.Equal("", session.Entry.ValueOf(ExchangeSlot.Section)); + Assert.Equal("", session.Entry.ValueOf(ExchangeSlot.Exchange1)); } /// CQ WW exchanges the CQ zone, so the CQ zone column is the one to use. @@ -445,7 +472,7 @@ public class OperatingPositionTests session.FillFromHistory(); - Assert.Equal("CT", session.Entry.ValueOf(ExchangeSlot.Section)); + Assert.Equal("00CT", session.Entry.ValueOf(ExchangeSlot.Exchange1)); } [Fact] @@ -485,9 +512,9 @@ public class OperatingPositionTests OperatingPosition session = Session(new CqWorldWide(ModeCategory.Digital)); session.Entry.Call = "JA1XYZ"; - // the boxes are the call, the report, the zone and the state + // the boxes are the call, the two reports, the zone and the state session.MoveFocus(forward: true); - Assert.Equal(2, session.Entry.Focus); + Assert.Equal(3, session.Entry.Focus); session.MoveFocus(forward: true); Assert.Equal(0, session.Entry.Focus); } @@ -498,10 +525,10 @@ public class OperatingPositionTests OperatingPosition session = Session(new CqWorldWide(ModeCategory.Digital)); session.Entry.Call = "K1ABC"; - session.MoveFocus(forward: true); - Assert.Equal(2, session.Entry.Focus); session.MoveFocus(forward: true); Assert.Equal(3, session.Entry.Focus); + session.MoveFocus(forward: true); + Assert.Equal(4, session.Entry.Focus); } [Fact] diff --git a/tests/Nonemm.Session.Tests/QsoEditorTests.cs b/tests/Nonemm.Session.Tests/QsoEditorTests.cs index 3ba1656..75e684d 100644 --- a/tests/Nonemm.Session.Tests/QsoEditorTests.cs +++ b/tests/Nonemm.Session.Tests/QsoEditorTests.cs @@ -58,6 +58,35 @@ public class QsoEditorTests public void AContestWithSerialNumbersGetsASentNumberColumn() => Assert.Contains(QsoField.SentNumber, Sweeps().Columns.Select(c => c.Field)); + /// Sweepstakes types the whole exchange in one box and logs it in four + /// columns, as N1MM does. + [Fact] + public void TheSweepstakesExchangeGetsAColumnPerPart() + { + IReadOnlyList fields = [.. Sweeps().Columns.Select(c => c.Field)]; + + Assert.Contains(QsoField.ReceivedNumber, fields); + Assert.Contains(QsoField.Precedence, fields); + Assert.Contains(QsoField.Check, fields); + Assert.Contains(QsoField.Section, fields); + Assert.DoesNotContain(QsoField.Exchange1, fields); + } + + /// A WAE row says whether it is a contact or QTC traffic in the exchange + /// column — `QSO`, `SQTC` or `RQTC` — and N1MM's log has that column. + [Fact] + public void TheWaeLogHasAColumnForTheQtcDesignator() + { + QsoEditor editor = new(new Wae(ModeCategory.Cw), Me, CountryFile.Parse(Countries)); + + Assert.Equal( + ["Series", "QTC?"], + editor.Columns + .Where(c => c.Field is QsoField.Exchange1 or QsoField.MiscText) + .Select(c => c.Label) + .ToList()); + } + [Fact] public void ChangingTheCallLooksTheCountryUpAgain() {