diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs
index cf74bab..0e853b9 100644
--- a/src/Nonemm.App/AppSession.cs
+++ b/src/Nonemm.App/AppSession.cs
@@ -92,11 +92,17 @@ public sealed class AppSession : IDisposable
return stored;
}
+ /// N1MM reads the `Contest` table when it opens a log, so the definition is
+ /// written again whenever a contest is opened, not only when it is created.
+ private void SaveDefinition(Contest contest) =>
+ Store.SaveContestDefinition(ContestDefinitions.For(contest));
+
public void OpenContest(int contestNumber)
{
ContestInstance instance = Store.Contest(contestNumber)
?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log");
Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
+ SaveDefinition(contest);
Logging = new LoggingSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries);
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
diff --git a/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml b/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml
index afd60b9..9bdf57f 100644
--- a/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml
+++ b/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml
@@ -24,7 +24,7 @@
-
+
diff --git a/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs b/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs
index 63c56e0..f90ee86 100644
--- a/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs
+++ b/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs
@@ -31,6 +31,11 @@ public sealed partial class ContestSetupDialog : Window
AssistedBox.SelectedIndex = 0;
TransmitterBox.ItemsSource = new[] { "ONE", "TWO", "LIMITED", "UNLIMITED", "SWL" };
TransmitterBox.SelectedIndex = 0;
+ OverlayBox.ItemsSource = new[]
+ {
+ "N/A", "ROOKIE", "BAND-LIMITED", "TB-WIRES", "OVER-50", "HQ", "NOVICE-TECH", "EXPERT",
+ };
+ OverlayBox.SelectedIndex = 0;
OperatorsBox.Text = session.Settings.Station.Callsign;
ShowChosen();
}
@@ -67,7 +72,7 @@ public sealed partial class ContestSetupDialog : Window
ModeCategory = Text(ModeBox),
AssistedCategory = Text(AssistedBox),
TransmitterCategory = Text(TransmitterBox),
- OverlayCategory = OverlayBox.Text ?? "",
+ OverlayCategory = Text(OverlayBox),
Operators = OperatorsBox.Text ?? "",
});
diff --git a/src/Nonemm.Contests/Rules/ArrlDx.cs b/src/Nonemm.Contests/Rules/ArrlDx.cs
index 027ecdb..93f8e14 100644
--- a/src/Nonemm.Contests/Rules/ArrlDx.cs
+++ b/src/Nonemm.Contests/Rules/ArrlDx.cs
@@ -35,9 +35,7 @@ public sealed class ArrlDx : Contest
public IReadOnlyList Modes => [mode];
public string SentExchangeFor(StationInfo me) =>
- IsNorthAmericanHome(me)
- ? $"{DefaultReport()} {StateOrProvince(me)}"
- : $"{DefaultReport()} {me.Power}";
+ IsNorthAmericanHome(me) ? StateOrProvince(me) : me.Power;
/// Only contacts across the W/VE line count, so a DX station working DX or
/// a W station working W scores nothing.
@@ -88,5 +86,4 @@ public sealed class ArrlDx : Contest
private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB";
- private string DefaultReport() => mode == ModeCategory.Cw ? "599" : "59";
}
diff --git a/src/Nonemm.Contests/Rules/CqWorldWide.cs b/src/Nonemm.Contests/Rules/CqWorldWide.cs
index 606d929..b76857c 100644
--- a/src/Nonemm.Contests/Rules/CqWorldWide.cs
+++ b/src/Nonemm.Contests/Rules/CqWorldWide.cs
@@ -31,8 +31,9 @@ public sealed class CqWorldWide : Contest
public IReadOnlyList Modes => [mode];
- public string SentExchangeFor(StationInfo me) =>
- $"{DefaultReport()} {me.CqZone}";
+ /// The report is fixed for the whole contest, so the sent exchange is the
+ /// zone alone, which is also what N1MM stores.
+ public string SentExchangeFor(StationInfo me) => me.CqZone.ToString();
public int PointsFor(QsoContext qso)
{
@@ -83,5 +84,4 @@ public sealed class CqWorldWide : Contest
private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB";
- private string DefaultReport() => mode == ModeCategory.Cw ? "599" : "59";
}
diff --git a/src/Nonemm.Contests/Rules/CqWpx.cs b/src/Nonemm.Contests/Rules/CqWpx.cs
index 8797dd3..410f116 100644
--- a/src/Nonemm.Contests/Rules/CqWpx.cs
+++ b/src/Nonemm.Contests/Rules/CqWpx.cs
@@ -40,7 +40,9 @@ public sealed class CqWpx : Contest
public IReadOnlyList Modes => [mode];
- public string SentExchangeFor(StationInfo me) => DefaultReport();
+ /// The serial number is generated per contact, so the stored exchange is
+ /// the number the contest starts at.
+ public string SentExchangeFor(StationInfo me) => "001";
public int PointsFor(QsoContext qso)
{
@@ -89,5 +91,4 @@ public sealed class CqWpx : Contest
_ => "RTTY",
};
- private string DefaultReport() => mode == ModeCategory.Phone ? "59" : "599";
}
diff --git a/src/Nonemm.Contests/Rules/GeneralLogging.cs b/src/Nonemm.Contests/Rules/GeneralLogging.cs
index 494b70c..19092b2 100644
--- a/src/Nonemm.Contests/Rules/GeneralLogging.cs
+++ b/src/Nonemm.Contests/Rules/GeneralLogging.cs
@@ -27,7 +27,7 @@ public sealed class GeneralLogging : Contest
public IReadOnlyList Modes => [];
- public string SentExchangeFor(StationInfo me) => "599";
+ public string SentExchangeFor(StationInfo me) => "";
public int PointsFor(QsoContext qso) => 0;
diff --git a/src/Nonemm.Contests/Rules/IaruHf.cs b/src/Nonemm.Contests/Rules/IaruHf.cs
index 5664b76..6208ad3 100644
--- a/src/Nonemm.Contests/Rules/IaruHf.cs
+++ b/src/Nonemm.Contests/Rules/IaruHf.cs
@@ -26,7 +26,7 @@ public sealed class IaruHf : Contest
public IReadOnlyList Modes => [ModeCategory.Cw, ModeCategory.Phone];
- public string SentExchangeFor(StationInfo me) => $"599 {me.ItuZone}";
+ public string SentExchangeFor(StationInfo me) => me.ItuZone.ToString();
public int PointsFor(QsoContext qso)
{
diff --git a/src/Nonemm.Contests/Rules/RttyRoundup.cs b/src/Nonemm.Contests/Rules/RttyRoundup.cs
index ddd3ffd..a2771f0 100644
--- a/src/Nonemm.Contests/Rules/RttyRoundup.cs
+++ b/src/Nonemm.Contests/Rules/RttyRoundup.cs
@@ -28,7 +28,7 @@ public sealed class RttyRoundup : Contest
public IReadOnlyList Modes => [ModeCategory.Digital];
public string SentExchangeFor(StationInfo me) =>
- me.State.Length > 0 ? $"599 {me.State}" : "599";
+ me.State.Length > 0 ? me.State : "001";
public int PointsFor(QsoContext qso) => 1;
diff --git a/src/Nonemm.Session/ContestDefinitions.cs b/src/Nonemm.Session/ContestDefinitions.cs
new file mode 100644
index 0000000..a08a2ca
--- /dev/null
+++ b/src/Nonemm.Session/ContestDefinitions.cs
@@ -0,0 +1,51 @@
+using Nonemm.Contests;
+using Nonemm.Core;
+using Nonemm.Storage;
+
+namespace Nonemm.Session;
+
+/// Turns a contest's rules into the definition row N1MM keeps in the `Contest`
+/// table, which it reads when it opens a log.
+public static class ContestDefinitions
+{
+ public static ContestDefinition For(Contest contest)
+ {
+ IReadOnlyList mults = contest.MultiplierNames;
+ return new ContestDefinition
+ {
+ Name = contest.Name,
+ DisplayName = contest.DisplayName,
+ CabrilloName = contest.CabrilloName,
+ Mode = ModeOf(contest),
+ DupeType = DupeTypeOf(contest.DupeScope),
+ Multiplier1Name = NameAt(mults, 0),
+ Multiplier2Name = NameAt(mults, 1),
+ Multiplier3Name = NameAt(mults, 2),
+ };
+ }
+
+ private static string ModeOf(Contest contest)
+ {
+ if (contest.Modes.Count != 1)
+ {
+ return "BOTH";
+ }
+ return contest.Modes[0] switch
+ {
+ ModeCategory.Cw => "CW",
+ ModeCategory.Phone => "SSB",
+ _ => "RTTY",
+ };
+ }
+
+ private static int DupeTypeOf(DupeScope scope) => scope switch
+ {
+ DupeScope.Once => 1,
+ DupeScope.PerBandAndMode => 3,
+ DupeScope.Never => 4,
+ _ => 2,
+ };
+
+ private static string NameAt(IReadOnlyList names, int at) =>
+ at < names.Count ? names[at] : "N/A";
+}
diff --git a/src/Nonemm.Storage/ContestDefinition.cs b/src/Nonemm.Storage/ContestDefinition.cs
new file mode 100644
index 0000000..979e6e4
--- /dev/null
+++ b/src/Nonemm.Storage/ContestDefinition.cs
@@ -0,0 +1,32 @@
+namespace Nonemm.Storage;
+
+/// The row N1MM keeps in the `Contest` table for each contest it knows. N1MM
+/// reads this row when it opens a log and throws "No current row" if the
+/// contest named by the log has none, so a log written here writes it too.
+public sealed record ContestDefinition
+{
+ public required string Name { get; init; }
+
+ public required string DisplayName { get; init; }
+
+ public required string CabrilloName { get; init; }
+
+ /// `CW`, `SSB`, `RTTY` or `BOTH`.
+ public required string Mode { get; init; }
+
+ /// 1 all bands, 2 each band, 3 each band and mode, 4 no check.
+ public int DupeType { get; init; } = 2;
+
+ public string Multiplier1Name { get; init; } = "N/A";
+
+ public string Multiplier2Name { get; init; } = "N/A";
+
+ public string Multiplier3Name { get; init; } = "N/A";
+
+ /// How many days the contest runs.
+ public int Period { get; init; } = 2;
+
+ public int PointsPerContact { get; init; } = 1;
+
+ public string CabrilloVersion { get; init; } = "3.0";
+}
diff --git a/src/Nonemm.Storage/ContestInstance.cs b/src/Nonemm.Storage/ContestInstance.cs
index 581071f..9937550 100644
--- a/src/Nonemm.Storage/ContestInstance.cs
+++ b/src/Nonemm.Storage/ContestInstance.cs
@@ -22,7 +22,9 @@ public sealed record ContestInstance
public string ModeCategory { get; init; } = "";
- public string OverlayCategory { get; init; } = "";
+ /// N1MM rejects a contest whose overlay is empty, so an entry with no
+ /// overlay says so with "N/A".
+ public string OverlayCategory { get; init; } = "N/A";
public string StationCategory { get; init; } = "";
diff --git a/src/Nonemm.Storage/LogStore.cs b/src/Nonemm.Storage/LogStore.cs
index 103b68f..6238c29 100644
--- a/src/Nonemm.Storage/LogStore.cs
+++ b/src/Nonemm.Storage/LogStore.cs
@@ -6,6 +6,10 @@ namespace Nonemm.Storage;
/// the program talks to this so the store can move elsewhere later.
public interface LogStore : IDisposable
{
+ /// Adds or replaces the contest's definition row. N1MM will not open a log
+ /// whose contest has no definition.
+ void SaveContestDefinition(ContestDefinition definition);
+
IReadOnlyList Contests();
ContestInstance? Contest(int contestNumber);
diff --git a/src/Nonemm.Storage/SqliteLogStore.cs b/src/Nonemm.Storage/SqliteLogStore.cs
index 479a5dc..6271c65 100644
--- a/src/Nonemm.Storage/SqliteLogStore.cs
+++ b/src/Nonemm.Storage/SqliteLogStore.cs
@@ -29,6 +29,31 @@ public sealed class SqliteLogStore : LogStore
return store;
}
+ public void SaveContestDefinition(ContestDefinition definition)
+ {
+ using SqliteCommand command = connection.CreateCommand();
+ command.CommandText = """
+ INSERT OR REPLACE INTO Contest
+ (Name, DisplayName, CabrilloName, Mode, DupeType, Multiplier1Name,
+ Multiplier2Name, Multiplier3Name, Period, PointsPerContact, CabrilloVersion)
+ VALUES
+ (@name, @display, @cabrillo, @mode, @dupe, @mult1, @mult2, @mult3,
+ @period, @points, @version)
+ """;
+ command.Parameters.AddWithValue("@name", definition.Name);
+ command.Parameters.AddWithValue("@display", definition.DisplayName);
+ command.Parameters.AddWithValue("@cabrillo", definition.CabrilloName);
+ command.Parameters.AddWithValue("@mode", definition.Mode);
+ command.Parameters.AddWithValue("@dupe", definition.DupeType);
+ command.Parameters.AddWithValue("@mult1", definition.Multiplier1Name);
+ command.Parameters.AddWithValue("@mult2", definition.Multiplier2Name);
+ command.Parameters.AddWithValue("@mult3", definition.Multiplier3Name);
+ command.Parameters.AddWithValue("@period", definition.Period);
+ command.Parameters.AddWithValue("@points", definition.PointsPerContact);
+ command.Parameters.AddWithValue("@version", definition.CabrilloVersion);
+ command.ExecuteNonQuery();
+ }
+
public IReadOnlyList Contests()
{
using SqliteCommand command = connection.CreateCommand();
diff --git a/tests/Nonemm.Session.Tests/ContestDefinitionsTests.cs b/tests/Nonemm.Session.Tests/ContestDefinitionsTests.cs
new file mode 100644
index 0000000..b022bc5
--- /dev/null
+++ b/tests/Nonemm.Session.Tests/ContestDefinitionsTests.cs
@@ -0,0 +1,42 @@
+using Nonemm.Contests.Rules;
+using Nonemm.Core;
+using Nonemm.Storage;
+
+namespace Nonemm.Session.Tests;
+
+public class ContestDefinitionsTests
+{
+ [Fact]
+ public void NamesAndModeComeFromTheContest()
+ {
+ ContestDefinition definition = ContestDefinitions.For(new CqWorldWide(ModeCategory.Cw));
+ Assert.Equal("CQWW", definition.Name);
+ Assert.Equal("CQ-WW-CW", definition.CabrilloName);
+ Assert.Equal("CW", definition.Mode);
+ }
+
+ [Fact]
+ public void MultiplierNamesFillUpToThree()
+ {
+ ContestDefinition definition = ContestDefinitions.For(new CqWorldWide(ModeCategory.Cw));
+ Assert.Equal("Zones", definition.Multiplier1Name);
+ Assert.Equal("Countries", definition.Multiplier2Name);
+ Assert.Equal("N/A", definition.Multiplier3Name);
+ }
+
+ [Theory]
+ [InlineData(typeof(Sweepstakes), 1)]
+ [InlineData(typeof(CqWorldWide), 2)]
+ [InlineData(typeof(IaruHf), 3)]
+ public void DupeScopeBecomesN1mmsDupeType(Type contest, int expected)
+ {
+ object made = contest == typeof(IaruHf)
+ ? new IaruHf()
+ : Activator.CreateInstance(contest, ModeCategory.Cw)!;
+ Assert.Equal(expected, ContestDefinitions.For((Contests.Contest)made).DupeType);
+ }
+
+ [Fact]
+ public void ContestWithSeveralModesIsBoth() =>
+ Assert.Equal("BOTH", ContestDefinitions.For(new IaruHf()).Mode);
+}
diff --git a/tests/Nonemm.Session.Tests/FakeLogStore.cs b/tests/Nonemm.Session.Tests/FakeLogStore.cs
index 91d99ad..6670fb3 100644
--- a/tests/Nonemm.Session.Tests/FakeLogStore.cs
+++ b/tests/Nonemm.Session.Tests/FakeLogStore.cs
@@ -10,6 +10,12 @@ public sealed class FakeLogStore : LogStore
{
private readonly List qsos = [];
private readonly List contests = [];
+ private readonly List definitions = [];
+
+ public IReadOnlyList Definitions => definitions;
+
+ public void SaveContestDefinition(ContestDefinition definition) =>
+ definitions.Add(definition);
public IReadOnlyList Contests() => contests;
diff --git a/tests/Nonemm.Storage.Tests/ContestInstanceTests.cs b/tests/Nonemm.Storage.Tests/ContestInstanceTests.cs
new file mode 100644
index 0000000..cb2fcc9
--- /dev/null
+++ b/tests/Nonemm.Storage.Tests/ContestInstanceTests.cs
@@ -0,0 +1,10 @@
+namespace Nonemm.Storage.Tests;
+
+public class ContestInstanceTests
+{
+ /// N1MM answers "Invalid Overlay Category:" and will not open a contest
+ /// whose overlay column is empty, so an entry with no overlay says "N/A".
+ [Fact]
+ public void OverlayCategoryDefaultsToNotApplicable() =>
+ Assert.Equal("N/A", new ContestInstance { ContestNumber = 1, ContestName = "CQWW" }.OverlayCategory);
+}