Take serial numbers from a pool the whole entry shares
Each station worked out its next number as the highest in its log plus one, so two operators typing at the same moment both sent the same number and one of the two contacts was wrong in the sponsor's log. SerialNumberPool holds the highest number taken per band, which is N1MM's QSONumbers array. A number is taken at the first character of the callsign, as N1MM takes it, and ReserveNr tells the other stations before the contact is worked. A number at or below one this station has used is answered with the whole pool, N1MM's QSONRS, and the station that is behind counts on from there. The pool also goes out when a contest is opened and when the numbers are started again. Contest.NumbersByBand says whether the numbers run per band or as one sequence for the station. CQ WPX runs one per band for a multi-two and a multi-multi; every other contest here runs one sequence. The band index in the message is N1MM's, so the array lines up with theirs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NZkFxHde1iifAQemFTHxf
This commit is contained in:
@@ -920,6 +920,19 @@ An incoming edit or delete is matched by contact id first. N1MM does not know
|
|||||||
our ids, so the call and the timestamp are the fallback — that is the pair N1MM
|
our ids, so the call and the timestamp are the fallback — that is the pair N1MM
|
||||||
itself keys a contact on.
|
itself keys a contact on.
|
||||||
|
|
||||||
|
**Serial numbers are taken from a pool the whole entry shares.** A number is
|
||||||
|
taken at the first character of the callsign, not when the contact is logged,
|
||||||
|
and `ReserveNr` tells the other stations at once, so two operators typing at the
|
||||||
|
same moment do not send the same number. A station whose number is one this
|
||||||
|
station has already used is answered with the whole pool — N1MM's `QSONRS`, one
|
||||||
|
number per band — and counts on from there. The pool goes out when a contest is
|
||||||
|
opened and when the numbers are started again, so a station joining a contest
|
||||||
|
already under way counts from the same place.
|
||||||
|
|
||||||
|
Most contests run one sequence for the whole station. CQ WPX runs one per band
|
||||||
|
for a multi-two and a multi-multi, which is the class the entry declares in
|
||||||
|
contest setup, and N1MM decides it the same way.
|
||||||
|
|
||||||
A contact that arrives is put into the log where its timestamp says it belongs
|
A contact that arrives is put into the log where its timestamp says it belongs
|
||||||
and the log is scored again, so of two stations that worked the same multiplier
|
and the log is scored again, so of two stations that worked the same multiplier
|
||||||
the one that worked it first keeps it, however late the message turns up.
|
the one that worked it first keeps it, however late the message turns up.
|
||||||
|
|||||||
+4
-4
@@ -365,10 +365,10 @@ which is the sending station's number, its computer name, the message type and
|
|||||||
the fields of that type. `%` and `~` cannot appear in a field, so N1MM writes
|
the fields of that type. `%` and `~` cannot appear in a field, so N1MM writes
|
||||||
`!` in their place. What is read and sent: contacts, edits, deletes, resyncs,
|
`!` in their place. What is read and sent: contacts, edits, deletes, resyncs,
|
||||||
`IAM`, the echo pair, chat, the pass frequency, transmit on and off, where a
|
`IAM`, the echo pair, chat, the pass frequency, transmit on and off, where a
|
||||||
station is tuned, the time of a station's last contact, and the four messages a
|
station is tuned, the time of a station's last contact, the two serial number
|
||||||
resync is made of. The rest of N1MM's forty-odd types — the score and sked windows, the
|
messages, and the four messages a resync is made of. The rest of N1MM's
|
||||||
log check, the spot lists, the serial-number pool — are passed over, which
|
forty-odd types — the score and sked windows, the log check, the spot lists —
|
||||||
N1MM's own reader also does for a type it does not know.
|
are passed over, which N1MM's own reader also does for a type it does not know.
|
||||||
|
|
||||||
**The version has to match.** N1MM compares the version in the beacon with its
|
**The version has to match.** N1MM compares the version in the beacon with its
|
||||||
own and, when they differ, puts up "Software versions must match. Update N1MM+."
|
own and, when they differ, puts up "Software versions must match. Update N1MM+."
|
||||||
|
|||||||
@@ -234,6 +234,10 @@ public sealed class AppSession : IDisposable
|
|||||||
// the other stations refuse to resynchronise with a station on another
|
// the other stations refuse to resynchronise with a station on another
|
||||||
// contest, and say so, which is only any use if they are told
|
// contest, and say so, which is only any use if they are told
|
||||||
_ = link?.SendContestNameAsync();
|
_ = link?.SendContestNameAsync();
|
||||||
|
// and where this station's serial numbers have got to, so a station
|
||||||
|
// joining a contest already under way counts from the same place.
|
||||||
|
// N1MM sends the pool at the same point
|
||||||
|
_ = link?.SendSerialNumbersAsync(Logging.Numbers.Snapshot());
|
||||||
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||||
Logging.Logged += (_, qso) =>
|
Logging.Logged += (_, qso) =>
|
||||||
{
|
{
|
||||||
@@ -250,6 +254,10 @@ public sealed class AppSession : IDisposable
|
|||||||
// and the same three to the other logging computers, which is a
|
// and the same three to the other logging computers, which is a
|
||||||
// different protocol on a different port
|
// different protocol on a different port
|
||||||
Logging.Logged += (_, qso) => _ = link?.SendLoggedAsync(qso);
|
Logging.Logged += (_, qso) => _ = link?.SendLoggedAsync(qso);
|
||||||
|
// the number is announced when it is taken rather than when the contact
|
||||||
|
// is logged, which is what keeps two operators off the same number
|
||||||
|
Logging.Numbers.Reserved += (_, taken) =>
|
||||||
|
_ = link?.SendReservedNumberAsync(taken.Number, SomewhereOn(taken.Band));
|
||||||
Logging.Edited += (_, change) =>
|
Logging.Edited += (_, change) =>
|
||||||
_ = link?.SendEditedAsync(change.Qso, change.OldCall, change.OldTimestampUtc);
|
_ = link?.SendEditedAsync(change.Qso, change.OldCall, change.OldTimestampUtc);
|
||||||
Logging.Deleted += (_, qso) => _ = link?.SendDeletedAsync(qso);
|
Logging.Deleted += (_, qso) => _ = link?.SendDeletedAsync(qso);
|
||||||
@@ -259,6 +267,11 @@ public sealed class AppSession : IDisposable
|
|||||||
ContestChanged?.Invoke(this, EventArgs.Empty);
|
ContestChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A frequency on the band a number was taken on. The message carries a
|
||||||
|
/// frequency because N1MM's does, and both ends read nothing off it but the
|
||||||
|
/// band.
|
||||||
|
private static Frequency SomewhereOn(Band? band) => band?.Low ?? Frequency.Zero;
|
||||||
|
|
||||||
/// One entry position per radio, and one when there is no radio at all.
|
/// One entry position per radio, and one when there is no radio at all.
|
||||||
private int PositionCount => Math.Max(1, Settings.Radios.Count(r => r.IsEnabled));
|
private int PositionCount => Math.Max(1, Settings.Radios.Count(r => r.IsEnabled));
|
||||||
|
|
||||||
@@ -326,6 +339,10 @@ public sealed class AppSession : IDisposable
|
|||||||
Dispatcher.UIThread.Post(ResetSerialNumbers);
|
Dispatcher.UIThread.Post(ResetSerialNumbers);
|
||||||
link.CallStacked += (_, stacked) =>
|
link.CallStacked += (_, stacked) =>
|
||||||
Dispatcher.UIThread.Post(() => TakeStackedCall(stacked));
|
Dispatcher.UIThread.Post(() => TakeStackedCall(stacked));
|
||||||
|
link.NumberReserved += (_, taken) =>
|
||||||
|
Dispatcher.UIThread.Post(() => TakeReservedNumber(taken));
|
||||||
|
link.SerialNumbersArrived += (_, numbers) =>
|
||||||
|
Dispatcher.UIThread.Post(() => Logging?.Numbers.Merge(numbers));
|
||||||
foreach (string peer in Settings.StationLinkPeers)
|
foreach (string peer in Settings.StationLinkPeers)
|
||||||
{
|
{
|
||||||
if (Peer(peer) is var (name, address, port))
|
if (Peer(peer) is var (name, address, port))
|
||||||
@@ -401,10 +418,28 @@ public sealed class AppSession : IDisposable
|
|||||||
if (Logging is not null)
|
if (Logging is not null)
|
||||||
{
|
{
|
||||||
Logging.SerialNumbersFrom = DateTime.UtcNow;
|
Logging.SerialNumbersFrom = DateTime.UtcNow;
|
||||||
|
_ = link?.SendSerialNumbersAsync(Logging.Numbers.Snapshot());
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A serial number another station has taken. It is noted here so this
|
||||||
|
/// station counts past it. A number at or below what this station has
|
||||||
|
/// already used says the other one is behind, and the whole pool goes back
|
||||||
|
/// so it can catch up, which is what N1MM answers with.
|
||||||
|
private void TakeReservedNumber(ReservedNumberMessage taken)
|
||||||
|
{
|
||||||
|
if (Logging is not { } logging)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!logging.Numbers.Note(taken.Number, Bands.ForFrequency(taken.Frequency)))
|
||||||
|
{
|
||||||
|
_ = link?.SendSerialNumbersAsync(logging.Numbers.Snapshot());
|
||||||
|
}
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
/// A callsign another station handed to this one. It goes on the call stack
|
/// A callsign another station handed to this one. It goes on the call stack
|
||||||
/// of the radio the operator is on, which is where the entry window takes
|
/// of the radio the operator is on, which is where the entry window takes
|
||||||
/// them from.
|
/// them from.
|
||||||
|
|||||||
@@ -46,23 +46,10 @@ public sealed record BandChangeRules(
|
|||||||
///
|
///
|
||||||
/// A station with a transmitter per band changes band by moving to another
|
/// A station with a transmitter per band changes band by moving to another
|
||||||
/// radio, so nothing is counted for it.
|
/// radio, so nothing is counted for it.
|
||||||
public static BandChangeRules ForCategory(ContestEntry entry)
|
public static BandChangeRules ForCategory(ContestEntry entry) =>
|
||||||
{
|
EntryCategories.IsMultiOne(entry) || EntryCategories.IsMultiTwo(entry)
|
||||||
if (!entry.OperatorCategory.StartsWith("MULTI", StringComparison.OrdinalIgnoreCase))
|
? None with { MinimumStay = MultiOneStay }
|
||||||
{
|
: None;
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// N1MM reads the number of transmitters off the operator category on
|
|
||||||
// Cabrillo 2.0 and off the transmitter category after it. Both are
|
|
||||||
// checked, so an entry written either way is read
|
|
||||||
bool one = Is(entry, "MULTI-ONE", "ONE");
|
|
||||||
bool two = Is(entry, "MULTI-TWO", "TWO");
|
|
||||||
return one || two ? None with { MinimumStay = MultiOneStay } : None;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool Is(ContestEntry entry, string operatorCategory, string transmitterCategory) =>
|
|
||||||
entry.OperatorCategory.Equals(operatorCategory, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| entry.TransmitterCategory.Equals(transmitterCategory, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public bool IsCounted => Max > 0;
|
public bool IsCounted => Max > 0;
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ public interface Contest
|
|||||||
/// contests the same question.
|
/// contests the same question.
|
||||||
bool UsesItuZones => false;
|
bool UsesItuZones => false;
|
||||||
|
|
||||||
|
/// True for a contest whose serial numbers run per band rather than as one
|
||||||
|
/// sequence for the whole station. The answer can depend on the class the
|
||||||
|
/// entry is in, which is why the entry is passed: CQ WPX numbers per band
|
||||||
|
/// for a multi-two or a multi-multi and runs one sequence for the rest.
|
||||||
|
/// N1MM asks its contests the same question.
|
||||||
|
bool NumbersByBand(ContestEntry entry) => false;
|
||||||
|
|
||||||
/// True for a contest that sends the time of the exchange as part of it.
|
/// True for a contest that sends the time of the exchange as part of it.
|
||||||
/// The time this station sent goes in the `MiscText` column, stamped as
|
/// The time this station sent goes in the `MiscText` column, stamped as
|
||||||
/// the message goes out. Only the BARTG RTTY contest asks for it.
|
/// the message goes out. Only the BARTG RTTY contest asks for it.
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Contests;
|
||||||
|
|
||||||
|
/// Reading the entry's class off the Cabrillo categories. N1MM reads the
|
||||||
|
/// number of transmitters off the operator category on Cabrillo 2.0 and off
|
||||||
|
/// the transmitter category after it, so both are checked here and an entry
|
||||||
|
/// written either way is read.
|
||||||
|
public static class EntryCategories
|
||||||
|
{
|
||||||
|
public static bool IsMultiOperator(ContestEntry entry) =>
|
||||||
|
entry.OperatorCategory.StartsWith("MULTI", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public static bool IsMultiOne(ContestEntry entry) => Is(entry, "MULTI-ONE", "ONE");
|
||||||
|
|
||||||
|
public static bool IsMultiTwo(ContestEntry entry) => Is(entry, "MULTI-TWO", "TWO");
|
||||||
|
|
||||||
|
public static bool IsMultiMulti(ContestEntry entry) => Is(entry, "MULTI-MULTI", "UNLIMITED");
|
||||||
|
|
||||||
|
private static bool Is(ContestEntry entry, string operatorCategory, string transmitterCategory) =>
|
||||||
|
IsMultiOperator(entry)
|
||||||
|
&& (entry.OperatorCategory.Equals(operatorCategory, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| entry.TransmitterCategory.Equals(transmitterCategory, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
@@ -45,6 +45,11 @@ public sealed class CqWpx : Contest
|
|||||||
|
|
||||||
public bool HasSerialNumbers => true;
|
public bool HasSerialNumbers => true;
|
||||||
|
|
||||||
|
/// A multi-two and a multi-multi run a number sequence per band; every
|
||||||
|
/// other class runs one sequence for the whole station.
|
||||||
|
public bool NumbersByBand(ContestEntry entry) =>
|
||||||
|
EntryCategories.IsMultiTwo(entry) || EntryCategories.IsMultiMulti(entry);
|
||||||
|
|
||||||
public IReadOnlyList<ModeCategory> Modes => [mode];
|
public IReadOnlyList<ModeCategory> Modes => [mode];
|
||||||
|
|
||||||
/// The serial number is generated per contact, so the stored exchange is
|
/// The serial number is generated per contact, so the stored exchange is
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
namespace Nonemm.Core;
|
||||||
|
|
||||||
|
/// N1MM's band numbering. A serial number pool is kept per band in an array of
|
||||||
|
/// these, and the array goes on the wire between the stations of a multi-
|
||||||
|
/// operator entry, so the numbering has to be N1MM's rather than one of our
|
||||||
|
/// own. Index 0 is a frequency on no band N1MM numbers, which includes 2190M
|
||||||
|
/// and 630M.
|
||||||
|
public static class N1mmBandIndex
|
||||||
|
{
|
||||||
|
/// How many slots N1MM's own array has, and how many go in a message.
|
||||||
|
public const int Slots = 32;
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, int> ByName = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["160M"] = 1,
|
||||||
|
["80M"] = 2,
|
||||||
|
["60M"] = 3,
|
||||||
|
["40M"] = 4,
|
||||||
|
["30M"] = 5,
|
||||||
|
["20M"] = 6,
|
||||||
|
["17M"] = 7,
|
||||||
|
["15M"] = 8,
|
||||||
|
["12M"] = 9,
|
||||||
|
["10M"] = 10,
|
||||||
|
["6M"] = 11,
|
||||||
|
["4M"] = 12,
|
||||||
|
["2M"] = 13,
|
||||||
|
["1.25M"] = 14,
|
||||||
|
["70CM"] = 15,
|
||||||
|
["33CM"] = 16,
|
||||||
|
["23CM"] = 17,
|
||||||
|
["13CM"] = 18,
|
||||||
|
["9CM"] = 19,
|
||||||
|
["6CM"] = 20,
|
||||||
|
["3CM"] = 21,
|
||||||
|
["1.25CM"] = 22,
|
||||||
|
["6MM"] = 23,
|
||||||
|
["4MM"] = 24,
|
||||||
|
["2.5MM"] = 25,
|
||||||
|
["2MM"] = 26,
|
||||||
|
["1MM"] = 27,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static int Of(Band? band) =>
|
||||||
|
band is not null && ByName.TryGetValue(band.Name, out int index) ? index : 0;
|
||||||
|
|
||||||
|
public static int Of(Frequency frequency) => Of(Bands.ForFrequency(frequency));
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Network;
|
||||||
|
|
||||||
|
/// A serial number another station has taken, and where it was working when it
|
||||||
|
/// took it. The band decides which of the pool's numbers it belongs to.
|
||||||
|
public sealed record ReservedNumberMessage(int Number, Frequency Frequency);
|
||||||
@@ -131,6 +131,14 @@ public sealed class StationLink : IDisposable
|
|||||||
/// owner is told and does it.
|
/// owner is told and does it.
|
||||||
public event EventHandler? SerialNumbersReset;
|
public event EventHandler? SerialNumbersReset;
|
||||||
|
|
||||||
|
/// A serial number another station has taken, with the frequency it took
|
||||||
|
/// it on. The pool is not the link's, so the owner is told and notes it.
|
||||||
|
public event EventHandler<ReservedNumberMessage>? NumberReserved;
|
||||||
|
|
||||||
|
/// The whole serial number pool from another station, one number per band
|
||||||
|
/// in N1MM's band order.
|
||||||
|
public event EventHandler<IReadOnlyList<int>>? SerialNumbersArrived;
|
||||||
|
|
||||||
/// A callsign another station handed to this one's call stack, with the
|
/// A callsign another station handed to this one's call stack, with the
|
||||||
/// contact behind it when there was one.
|
/// contact behind it when there was one.
|
||||||
public event EventHandler<StackedCall>? CallStacked;
|
public event EventHandler<StackedCall>? CallStacked;
|
||||||
@@ -424,6 +432,30 @@ public sealed class StationLink : IDisposable
|
|||||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Says which serial number this station has taken for the contact it is
|
||||||
|
/// about to work. It goes out at the first character of the callsign, so
|
||||||
|
/// the other operators' next number is above this one.
|
||||||
|
public Task<int> SendReservedNumberAsync(
|
||||||
|
int number,
|
||||||
|
Frequency frequency,
|
||||||
|
CancellationToken cancellation = default) =>
|
||||||
|
SendAsync(
|
||||||
|
StationMessages.ReserveNumber(number, frequency, StationNumber, computerName),
|
||||||
|
cancellation);
|
||||||
|
|
||||||
|
/// Sends the whole pool, which is what a station answers with when the
|
||||||
|
/// number another one has taken is one this station has already used.
|
||||||
|
public Task<int> SendSerialNumbersAsync(
|
||||||
|
IReadOnlyList<int> numbers,
|
||||||
|
CancellationToken cancellation = default) =>
|
||||||
|
SendAsync(
|
||||||
|
StationMessages.SerialNumbers(
|
||||||
|
Log?.ContestName ?? "",
|
||||||
|
numbers,
|
||||||
|
StationNumber,
|
||||||
|
computerName),
|
||||||
|
cancellation);
|
||||||
|
|
||||||
/// Tells every station to start its serial numbers again at one.
|
/// Tells every station to start its serial numbers again at one.
|
||||||
public Task<int> SendResetSerialNumbersAsync(CancellationToken cancellation = default) =>
|
public Task<int> SendResetSerialNumbersAsync(CancellationToken cancellation = default) =>
|
||||||
SendAsync(StationMessages.ResetSerialNumbers(StationNumber, computerName), cancellation);
|
SendAsync(StationMessages.ResetSerialNumbers(StationNumber, computerName), cancellation);
|
||||||
@@ -893,6 +925,21 @@ public sealed class StationLink : IDisposable
|
|||||||
case "RESETQSONRS":
|
case "RESETQSONRS":
|
||||||
SerialNumbersReset?.Invoke(this, EventArgs.Empty);
|
SerialNumbersReset?.Invoke(this, EventArgs.Empty);
|
||||||
break;
|
break;
|
||||||
|
case "RESERVENR":
|
||||||
|
NumberReserved?.Invoke(
|
||||||
|
this,
|
||||||
|
new ReservedNumberMessage(
|
||||||
|
record.Number(0),
|
||||||
|
Frequency.FromKilohertz(record.Decimal(1) / 100)));
|
||||||
|
break;
|
||||||
|
case "QSONRS":
|
||||||
|
// the pool is per contest, so a station logging another one is
|
||||||
|
// counting something else
|
||||||
|
if (Log is null || record.Field(0).Equals(Log.ContestName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
SerialNumbersArrived?.Invoke(this, StationMessages.ReadSerialNumbers(record));
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "STACKANOTHERCALL":
|
case "STACKANOTHERCALL":
|
||||||
CallStacked?.Invoke(this, new StackedCall(record.Field(0), null, station.ComputerName));
|
CallStacked?.Invoke(this, new StackedCall(record.Field(0), null, station.ComputerName));
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ namespace Nonemm.Network;
|
|||||||
/// | `TIME` | the sender's clock | yes | yes |
|
/// | `TIME` | the sender's clock | yes | yes |
|
||||||
/// | `MASTER` | which station is the master for the cluster and the time | yes | yes |
|
/// | `MASTER` | which station is the master for the cluster and the time | yes | yes |
|
||||||
/// | `RESETQSONRS` | start the serial numbers again at one | yes | yes |
|
/// | `RESETQSONRS` | start the serial numbers again at one | yes | yes |
|
||||||
|
/// | `ReserveNr` | a serial number the sender has taken for a contact | yes | yes |
|
||||||
|
/// | `QSONRS` | the highest serial number taken on each band | yes | yes |
|
||||||
/// | `STACKCALL` | a contact for another station's call stack | yes | yes |
|
/// | `STACKCALL` | a contact for another station's call stack | yes | yes |
|
||||||
/// | `STACKANOTHERCALL` | a callsign for another station's call stack | yes | yes |
|
/// | `STACKANOTHERCALL` | a callsign for another station's call stack | yes | yes |
|
||||||
/// | `STATUS` | everything the network status window shows about a station | yes | yes |
|
/// | `STATUS` | everything the network status window shows about a station | yes | yes |
|
||||||
@@ -34,8 +36,8 @@ namespace Nonemm.Network;
|
|||||||
/// | `CONFIRMED` | the answer to a checksum, and what moves a resync on | yes | yes |
|
/// | `CONFIRMED` | the answer to a checksum, and what moves a resync on | yes | yes |
|
||||||
/// | `DELETEQS` | drop your copies of one station's contacts in this block | yes | yes |
|
/// | `DELETEQS` | drop your copies of one station's contacts in this block | yes | yes |
|
||||||
///
|
///
|
||||||
/// The rest — the score and sked windows, the log check, the spot lists, the
|
/// The rest — the score and sked windows, the log check, the spot lists — are
|
||||||
/// serial-number pool — are passed over. An unknown type is not an error: N1MM
|
/// passed over. An unknown type is not an error: N1MM
|
||||||
/// adds them between versions, and a station that cannot read one is no worse
|
/// adds them between versions, and a station that cannot read one is no worse
|
||||||
/// off than a station that has not been told.
|
/// off than a station that has not been told.
|
||||||
public static class StationMessages
|
public static class StationMessages
|
||||||
@@ -307,6 +309,30 @@ public static class StationMessages
|
|||||||
public static StationRecord ResetSerialNumbers(int stationNumber, string computerName) =>
|
public static StationRecord ResetSerialNumbers(int stationNumber, string computerName) =>
|
||||||
Record("RESETQSONRS", stationNumber, computerName, []);
|
Record("RESETQSONRS", stationNumber, computerName, []);
|
||||||
|
|
||||||
|
/// A serial number this station has taken for a contact it is about to
|
||||||
|
/// work, with the frequency it was taken on. The other stations count past
|
||||||
|
/// it, so nobody sends the same number twice.
|
||||||
|
public static StationRecord ReserveNumber(
|
||||||
|
int number,
|
||||||
|
Frequency frequency,
|
||||||
|
int stationNumber,
|
||||||
|
string computerName) =>
|
||||||
|
Record("ReserveNr", stationNumber, computerName, [Text(number), Hundredths(frequency)]);
|
||||||
|
|
||||||
|
/// The highest serial number taken on each band, in N1MM's band order. It
|
||||||
|
/// is the answer to a `ReserveNr` that has fallen behind: every station
|
||||||
|
/// keeps the higher of the two for each band, so the sender catches up.
|
||||||
|
public static StationRecord SerialNumbers(
|
||||||
|
string contestName,
|
||||||
|
IReadOnlyList<int> numbers,
|
||||||
|
int stationNumber,
|
||||||
|
string computerName) =>
|
||||||
|
Record("QSONRS", stationNumber, computerName, [contestName, .. numbers.Select(Text)]);
|
||||||
|
|
||||||
|
/// The numbers out of a `QSONRS` message, one per band.
|
||||||
|
public static IReadOnlyList<int> ReadSerialNumbers(StationRecord record) =>
|
||||||
|
[.. Enumerable.Range(1, record.Fields.Count - 1).Select(record.Number)];
|
||||||
|
|
||||||
/// A contact for another station's call stack: a station this one has heard
|
/// A contact for another station's call stack: a station this one has heard
|
||||||
/// but is not going to work, handed to the position that will.
|
/// but is not going to work, handed to the position that will.
|
||||||
public static StationRecord StackContact(Qso qso, int stationNumber, string computerName) =>
|
public static StationRecord StackContact(Qso qso, int stationNumber, string computerName) =>
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ public sealed class ContestSession
|
|||||||
Log = new ContestLog(contest, me, countries, instance.Entry) { History = History };
|
Log = new ContestLog(contest, me, countries, instance.Entry) { History = History };
|
||||||
Log.Restore(store.Qsos(instance.ContestNumber));
|
Log.Restore(store.Qsos(instance.ContestNumber));
|
||||||
Editor = new QsoEditor(contest, me, countries);
|
Editor = new QsoEditor(contest, me, countries);
|
||||||
SentNumber = NextSentNumber();
|
Numbers = new SerialNumberPool { ByBand = contest.NumbersByBand(instance.Entry) };
|
||||||
|
SeedNumbers();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Contest Contest { get; }
|
public Contest Contest { get; }
|
||||||
@@ -51,8 +52,11 @@ public sealed class ContestSession
|
|||||||
/// What was published about the stations in this contest before it started.
|
/// What was published about the stations in this contest before it started.
|
||||||
public CallHistory History { get; }
|
public CallHistory History { get; }
|
||||||
|
|
||||||
/// Serial numbers count up across the station, not per radio.
|
/// The serial numbers the whole entry has taken, one highest per band. A
|
||||||
public int SentNumber { get; private set; }
|
/// number is taken from here when the operator starts a contact, not when
|
||||||
|
/// it is logged, and the other stations are told at once, so two operators
|
||||||
|
/// typing at the same moment cannot send the same number.
|
||||||
|
public SerialNumberPool Numbers { get; }
|
||||||
|
|
||||||
public string Operator { get; set; } = "";
|
public string Operator { get; set; } = "";
|
||||||
|
|
||||||
@@ -74,10 +78,22 @@ public sealed class ContestSession
|
|||||||
set
|
set
|
||||||
{
|
{
|
||||||
field = value;
|
field = value;
|
||||||
SentNumber = NextSentNumber();
|
SeedNumbers();
|
||||||
|
NumbersRestarted?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The numbers have been started again, so a position holding one it took
|
||||||
|
/// earlier has to let it go.
|
||||||
|
public event EventHandler? NumbersRestarted;
|
||||||
|
|
||||||
|
/// The number a contact on this band would carry. Nothing is taken by
|
||||||
|
/// asking; `ReserveNumberOn` is what takes one.
|
||||||
|
public int NextNumberOn(Band? band) => Numbers.Next(band);
|
||||||
|
|
||||||
|
/// Takes the next number for a contact about to be worked on this band.
|
||||||
|
public int ReserveNumberOn(Band? band) => Numbers.Take(band);
|
||||||
|
|
||||||
public event EventHandler? Changed;
|
public event EventHandler? Changed;
|
||||||
|
|
||||||
public event EventHandler<Qso>? Logged;
|
public event EventHandler<Qso>? Logged;
|
||||||
@@ -99,7 +115,7 @@ public sealed class ContestSession
|
|||||||
{
|
{
|
||||||
Log.Replace(stored);
|
Log.Replace(stored);
|
||||||
}
|
}
|
||||||
SentNumber = NextSentNumber();
|
Numbers.Record(stored.SentNumber, stored.Band);
|
||||||
Logged?.Invoke(this, stored);
|
Logged?.Invoke(this, stored);
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
return stored;
|
return stored;
|
||||||
@@ -189,8 +205,9 @@ public sealed class ContestSession
|
|||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
Log.Insert(store.Add(FromNetwork(qso)));
|
Qso arrived = store.Add(FromNetwork(qso));
|
||||||
SentNumber = NextSentNumber();
|
Log.Insert(arrived);
|
||||||
|
Numbers.Record(arrived.SentNumber, arrived.Band);
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -252,16 +269,15 @@ public sealed class ContestSession
|
|||||||
return edit;
|
return edit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The next serial number to send. A contest whose `ResetSNEverySession`
|
/// Fills the pool from the numbers the log already holds. A contest whose
|
||||||
/// is set starts over in each session, so only the contacts of the session
|
/// `ResetSNEverySession` is set starts over in each session, so only the
|
||||||
/// we are in count.
|
/// contacts of the session we are in count.
|
||||||
private int NextSentNumber()
|
private void SeedNumbers()
|
||||||
{
|
{
|
||||||
IEnumerable<Qso> counted = Contest.ResetsSerialNumbersEachSession
|
IEnumerable<Qso> counted = Contest.ResetsSerialNumbersEachSession
|
||||||
? Log.Qsos.Where(q =>
|
? Log.Qsos.Where(q =>
|
||||||
Contest.SessionOf(q.TimestampUtc) == Contest.SessionOf(DateTime.UtcNow))
|
Contest.SessionOf(q.TimestampUtc) == Contest.SessionOf(DateTime.UtcNow))
|
||||||
: Log.Qsos;
|
: Log.Qsos;
|
||||||
counted = counted.Where(q => q.TimestampUtc >= SerialNumbersFrom);
|
Numbers.SeedFrom(counted.Where(q => q.TimestampUtc >= SerialNumbersFrom));
|
||||||
return counted.Select(q => q.SentNumber).DefaultIfEmpty(0).Max() + 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,24 @@ public sealed class EntryFields
|
|||||||
|
|
||||||
public IReadOnlyList<ExchangeField> Exchange { get; }
|
public IReadOnlyList<ExchangeField> Exchange { get; }
|
||||||
|
|
||||||
public string Call { get; set; } = "";
|
/// Raised when the callsign box goes from empty to holding something. That
|
||||||
|
/// is where N1MM takes the serial number for the contact, so the number is
|
||||||
|
/// out on the network before the contact is worked.
|
||||||
|
public event EventHandler? CallStarted;
|
||||||
|
|
||||||
|
public string Call
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
bool wasEmpty = field.Trim().Length == 0;
|
||||||
|
field = value;
|
||||||
|
if (wasEmpty && field.Trim().Length > 0)
|
||||||
|
{
|
||||||
|
CallStarted?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} = "";
|
||||||
|
|
||||||
/// 0 is the callsign box; 1 and up are the exchange boxes in order.
|
/// 0 is the callsign box; 1 and up are the exchange boxes in order.
|
||||||
public int Focus { get; private set; }
|
public int Focus { get; private set; }
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ public sealed class OperatingPosition
|
|||||||
// contact is logged, so the other radio is not left showing the number
|
// contact is logged, so the other radio is not left showing the number
|
||||||
// that has just gone
|
// that has just gone
|
||||||
Session.Logged += (_, _) => FillSentExchange();
|
Session.Logged += (_, _) => FillSentExchange();
|
||||||
|
Session.NumbersRestarted += (_, _) => LetTheNumberGo();
|
||||||
|
Entry.CallStarted += (_, _) => ReserveNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ContestSession Session { get; }
|
public ContestSession Session { get; }
|
||||||
@@ -33,6 +35,10 @@ public sealed class OperatingPosition
|
|||||||
/// With no radio connected this stays where it was last typed.
|
/// With no radio connected this stays where it was last typed.
|
||||||
public Frequency Frequency { get; private set; } = Bands.Band20M.Low;
|
public Frequency Frequency { get; private set; } = Bands.Band20M.Low;
|
||||||
|
|
||||||
|
/// The band this position is on. A contest that numbers per band takes the
|
||||||
|
/// serial number against it.
|
||||||
|
public Band? Band => Bands.ForFrequency(Frequency);
|
||||||
|
|
||||||
/// Where the radio transmits when working split, and zero when it is not.
|
/// Where the radio transmits when working split, and zero when it is not.
|
||||||
public Frequency TransmitFrequency { get; private set; } = Frequency.Zero;
|
public Frequency TransmitFrequency { get; private set; } = Frequency.Zero;
|
||||||
|
|
||||||
@@ -64,7 +70,35 @@ public sealed class OperatingPosition
|
|||||||
|
|
||||||
public QsoEditor Editor => Session.Editor;
|
public QsoEditor Editor => Session.Editor;
|
||||||
|
|
||||||
public int SentNumber => Session.SentNumber;
|
/// The serial number this position sends. It is taken from the pool at the
|
||||||
|
/// first character of the callsign and held until the contact is logged;
|
||||||
|
/// before that it is what the next contact on this band would carry.
|
||||||
|
public int SentNumber => reserved ?? Session.NextNumberOn(Band);
|
||||||
|
|
||||||
|
private int? reserved;
|
||||||
|
|
||||||
|
/// Takes this position's number, unless it has one already. N1MM takes it
|
||||||
|
/// at the first character of the callsign rather than at logging, so the
|
||||||
|
/// number is on the network while the contact is still being worked and
|
||||||
|
/// the other operator's next number goes above it.
|
||||||
|
private void ReserveNumber()
|
||||||
|
{
|
||||||
|
if (reserved is not null || !Contest.HasSerialNumbers)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reserved = Session.ReserveNumberOn(Band);
|
||||||
|
FillSentExchange();
|
||||||
|
Session.NotifyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gives up the number this position holds, so the next contact takes a
|
||||||
|
/// fresh one. A wipe keeps it, as N1MM does: the contact is still to come.
|
||||||
|
private void LetTheNumberGo()
|
||||||
|
{
|
||||||
|
reserved = null;
|
||||||
|
FillSentExchange();
|
||||||
|
}
|
||||||
|
|
||||||
public void Tune(Frequency frequency, Mode? mode = null, Frequency? transmitFrequency = null)
|
public void Tune(Frequency frequency, Mode? mode = null, Frequency? transmitFrequency = null)
|
||||||
{
|
{
|
||||||
@@ -229,6 +263,9 @@ public sealed class OperatingPosition
|
|||||||
}
|
}
|
||||||
StampSentTime();
|
StampSentTime();
|
||||||
Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim())));
|
Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim())));
|
||||||
|
// the number went with the contact, so the next one starts again from
|
||||||
|
// the pool
|
||||||
|
reserved = null;
|
||||||
Entry.Clear();
|
Entry.Clear();
|
||||||
FillSentExchange();
|
FillSentExchange();
|
||||||
OtherName = "";
|
OtherName = "";
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Session;
|
||||||
|
|
||||||
|
/// A serial number taken for a contact that has not been logged yet, and the
|
||||||
|
/// band it was taken on.
|
||||||
|
public sealed record ReservedNumber(int Number, Band? Band);
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Session;
|
||||||
|
|
||||||
|
/// The serial numbers a multi-operator entry has taken, one highest number per
|
||||||
|
/// band. Every station of the entry keeps a copy and they are held together by
|
||||||
|
/// the network: a station takes a number before it works the contact and says
|
||||||
|
/// so, so two operators typing at the same moment do not send the same number.
|
||||||
|
///
|
||||||
|
/// This is N1MM's `QSONumbers` array. `ByBand` is its `IsQsoNumbersByBand`: a
|
||||||
|
/// contest that numbers per band takes the next number from the band being
|
||||||
|
/// worked, and one that runs a single sequence takes it from the highest of
|
||||||
|
/// them all.
|
||||||
|
public sealed class SerialNumberPool
|
||||||
|
{
|
||||||
|
private readonly int[] highest = new int[N1mmBandIndex.Slots];
|
||||||
|
|
||||||
|
public bool ByBand { get; set; }
|
||||||
|
|
||||||
|
/// A number this station has taken, with the frequency it was taken on.
|
||||||
|
/// The other stations are told, so they count past it.
|
||||||
|
public event EventHandler<ReservedNumber>? Reserved;
|
||||||
|
|
||||||
|
/// The number the next contact would carry. Nothing is taken by asking.
|
||||||
|
public int Next(Band? band) => Highest(band) + 1;
|
||||||
|
|
||||||
|
/// Takes the next number for a band and tells the other stations. Called
|
||||||
|
/// once per contact, when the operator starts typing a callsign, which is
|
||||||
|
/// where N1MM takes one.
|
||||||
|
public int Take(Band? band)
|
||||||
|
{
|
||||||
|
int number = Next(band);
|
||||||
|
Record(number, band);
|
||||||
|
Reserved?.Invoke(this, new ReservedNumber(number, band));
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A number another station has taken. False when it clashes with what is
|
||||||
|
/// held here — the other station is behind, and the caller answers with the
|
||||||
|
/// whole pool so it can catch up.
|
||||||
|
public bool Note(int number, Band? band)
|
||||||
|
{
|
||||||
|
int at = N1mmBandIndex.Of(band);
|
||||||
|
if (ByBand)
|
||||||
|
{
|
||||||
|
if (number <= highest[at])
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
highest[at] = number;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (number > Peak())
|
||||||
|
{
|
||||||
|
highest[at] = number;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// the same number back from the station it was sent to is the message
|
||||||
|
// going round, not a clash
|
||||||
|
return highest[at] == number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole pool from another station, which is N1MM's `QSONRS`. The
|
||||||
|
/// higher of the two is kept for each band, so a station that has been off
|
||||||
|
/// the network cannot pull the numbers back down.
|
||||||
|
public void Merge(IReadOnlyList<int> numbers)
|
||||||
|
{
|
||||||
|
for (int at = 0; at < highest.Length && at < numbers.Count; at++)
|
||||||
|
{
|
||||||
|
highest[at] = Math.Max(highest[at], numbers[at]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What goes in a `QSONRS` message.
|
||||||
|
public IReadOnlyList<int> Snapshot() => [.. highest];
|
||||||
|
|
||||||
|
/// The numbers the log already holds, which is what the pool starts from
|
||||||
|
/// when a contest is opened and after the numbers are started again.
|
||||||
|
public void SeedFrom(IEnumerable<Qso> qsos)
|
||||||
|
{
|
||||||
|
Array.Clear(highest);
|
||||||
|
foreach (Qso qso in qsos)
|
||||||
|
{
|
||||||
|
Record(qso.SentNumber, qso.Band);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notes a number without telling anyone: a contact that has been logged,
|
||||||
|
/// here or at another station.
|
||||||
|
public void Record(int number, Band? band)
|
||||||
|
{
|
||||||
|
int at = N1mmBandIndex.Of(band);
|
||||||
|
highest[at] = Math.Max(highest[at], number);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int Highest(Band? band) => ByBand ? highest[N1mmBandIndex.Of(band)] : Peak();
|
||||||
|
|
||||||
|
private int Peak() => highest.Max();
|
||||||
|
}
|
||||||
@@ -78,4 +78,23 @@ public class CqWpxTests
|
|||||||
Assert.Equal(0, verdict.Points);
|
Assert.Equal(0, verdict.Points);
|
||||||
Assert.Single(verdict.NewMultipliers);
|
Assert.Single(verdict.NewMultipliers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A multi-two and a multi-multi run a serial number sequence per band; a
|
||||||
|
/// single operator and a multi-one run one sequence. N1MM asks its
|
||||||
|
/// contests the same question before it takes a number.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("SINGLE-OP", "ONE", false)]
|
||||||
|
[InlineData("MULTI-ONE", "ONE", false)]
|
||||||
|
[InlineData("MULTI-TWO", "TWO", true)]
|
||||||
|
[InlineData("MULTI-MULTI", "UNLIMITED", true)]
|
||||||
|
[InlineData("MULTI-OP", "TWO", true)]
|
||||||
|
[InlineData("MULTI-OP", "UNLIMITED", true)]
|
||||||
|
public void OnlyAMultiTwoOrMultiMultiNumbersPerBand(string operators, string transmitters, bool byBand) =>
|
||||||
|
Assert.Equal(
|
||||||
|
byBand,
|
||||||
|
new CqWpx(ModeCategory.Cw).NumbersByBand(new ContestEntry
|
||||||
|
{
|
||||||
|
OperatorCategory = operators,
|
||||||
|
TransmitterCategory = transmitters,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -611,4 +611,50 @@ public class StationLinkTests
|
|||||||
SentNumber = 41,
|
SentNumber = 41,
|
||||||
StationName = "RUN-PC",
|
StationName = "RUN-PC",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The number one station has taken for the contact it is working reaches
|
||||||
|
/// the other, with the band it was taken on.
|
||||||
|
[Fact]
|
||||||
|
public async Task AReservedSerialNumberReachesTheOtherStation()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
ReservedNumberMessage? taken = null;
|
||||||
|
two.NumberReserved += (_, number) => taken = number;
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
|
||||||
|
|
||||||
|
await one.SendReservedNumberAsync(41, Frequency.FromKilohertz(14_025));
|
||||||
|
|
||||||
|
await WaitForAsync(() => taken is not null);
|
||||||
|
Assert.Equal(41, taken!.Number);
|
||||||
|
Assert.Equal(Bands.Band20M, Bands.ForFrequency(taken.Frequency));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The answer to a number that clashes: the whole pool, one number per
|
||||||
|
/// band, so the station that is behind catches up.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheWholeSerialNumberPoolCanBeSent()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
IReadOnlyList<int>? numbers = null;
|
||||||
|
two.SerialNumbersArrived += (_, sent) => numbers = sent;
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
|
||||||
|
int[] pool = new int[N1mmBandIndex.Slots];
|
||||||
|
pool[N1mmBandIndex.Of(Bands.Band20M)] = 12;
|
||||||
|
|
||||||
|
await one.SendSerialNumbersAsync(pool);
|
||||||
|
|
||||||
|
await WaitForAsync(() => numbers is not null);
|
||||||
|
Assert.Equal(12, numbers![N1mmBandIndex.Of(Bands.Band20M)]);
|
||||||
|
Assert.Equal(N1mmBandIndex.Slots, numbers.Count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,11 +190,11 @@ public class NetworkedContactsTests
|
|||||||
(ContestSession session, _) = Station();
|
(ContestSession session, _) = Station();
|
||||||
session.Add(Arriving("JA1XYZ", 1) with { Id = Qso.NewId(), SentNumber = 1 });
|
session.Add(Arriving("JA1XYZ", 1) with { Id = Qso.NewId(), SentNumber = 1 });
|
||||||
session.Add(Arriving("JA2XYZ", 2) with { Id = Qso.NewId(), SentNumber = 2 });
|
session.Add(Arriving("JA2XYZ", 2) with { Id = Qso.NewId(), SentNumber = 2 });
|
||||||
Assert.Equal(3, session.SentNumber);
|
Assert.Equal(3, session.NextNumberOn(null));
|
||||||
|
|
||||||
session.SerialNumbersFrom = DateTime.UtcNow;
|
session.SerialNumbersFrom = DateTime.UtcNow;
|
||||||
|
|
||||||
Assert.Equal(1, session.SentNumber);
|
Assert.Equal(1, session.NextNumberOn(null));
|
||||||
Assert.Equal([1, 2], session.Log.Qsos.Select(q => q.SentNumber));
|
Assert.Equal([1, 2], session.Log.Qsos.Select(q => q.SentNumber));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -727,4 +727,64 @@ public class OperatingPositionTests
|
|||||||
Assert.True(session.StackAnother());
|
Assert.True(session.StackAnother());
|
||||||
Assert.False(session.RestoreWiped());
|
Assert.False(session.RestoreWiped());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// N1MM takes the serial number at the first character of the callsign, not
|
||||||
|
/// at logging, so the number is on the network while the contact is still
|
||||||
|
/// being worked. A second position takes the next one.
|
||||||
|
[Fact]
|
||||||
|
public void TypingACallTakesTheSerialNumber()
|
||||||
|
{
|
||||||
|
ContestSession shared = Shared(new FakeLogStore(), new CqWpx(ModeCategory.Cw));
|
||||||
|
OperatingPosition run = new(shared, 1);
|
||||||
|
OperatingPosition mult = new(shared, 2);
|
||||||
|
|
||||||
|
run.Entry.Call = "J";
|
||||||
|
|
||||||
|
Assert.Equal(1, run.SentNumber);
|
||||||
|
Assert.Equal(2, mult.SentNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Without the pool both positions showed the same number until one of them
|
||||||
|
/// logged. The number the other station takes counts here as well.
|
||||||
|
[Fact]
|
||||||
|
public void ANumberAnotherStationTookIsNotSentHere()
|
||||||
|
{
|
||||||
|
OperatingPosition session = Session(new CqWpx(ModeCategory.Cw));
|
||||||
|
|
||||||
|
session.Session.Numbers.Note(5, Bands.Band20M);
|
||||||
|
|
||||||
|
Assert.Equal(6, session.SentNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number is held until the contact is logged, so a wipe keeps it and
|
||||||
|
/// the next contact takes a fresh one.
|
||||||
|
[Fact]
|
||||||
|
public void TheNumberIsHeldUntilTheContactIsLogged()
|
||||||
|
{
|
||||||
|
OperatingPosition session = Session(new CqWpx(ModeCategory.Cw));
|
||||||
|
session.Entry.Call = "JA1XYZ";
|
||||||
|
Assert.Equal(1, session.SentNumber);
|
||||||
|
|
||||||
|
session.Wipe();
|
||||||
|
Assert.Equal(1, session.SentNumber);
|
||||||
|
|
||||||
|
session.Entry.Call = "JA2XYZ";
|
||||||
|
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
|
||||||
|
session.Entry.Set(ExchangeSlot.SerialNumber, "12");
|
||||||
|
Assert.Equal(1, session.LogContact().SentNumber);
|
||||||
|
|
||||||
|
session.Entry.Call = "JA3XYZ";
|
||||||
|
Assert.Equal(2, session.SentNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A contest without serial numbers has nothing to take.
|
||||||
|
[Fact]
|
||||||
|
public void NothingIsTakenInAContestThatSendsNoNumbers()
|
||||||
|
{
|
||||||
|
OperatingPosition session = Session();
|
||||||
|
|
||||||
|
session.Entry.Call = "JA1XYZ";
|
||||||
|
|
||||||
|
Assert.Equal(1, session.Session.Numbers.Next(Bands.Band20M));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Session.Tests;
|
||||||
|
|
||||||
|
public class SerialNumberPoolTests
|
||||||
|
{
|
||||||
|
private static Qso Worked(Band band, int number) => new()
|
||||||
|
{
|
||||||
|
Id = Qso.NewId(),
|
||||||
|
TimestampUtc = new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
ContestName = "CQWPX",
|
||||||
|
Call = Callsign.Parse("JA1XYZ"),
|
||||||
|
Frequency = band.Low,
|
||||||
|
Mode = Modes.Cw,
|
||||||
|
SentNumber = number,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheFirstNumberIsOne() =>
|
||||||
|
Assert.Equal(1, new SerialNumberPool().Next(Bands.Band20M));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TakingANumberMovesTheNextOneOn()
|
||||||
|
{
|
||||||
|
SerialNumberPool pool = new();
|
||||||
|
|
||||||
|
Assert.Equal(1, pool.Take(Bands.Band20M));
|
||||||
|
Assert.Equal(2, pool.Next(Bands.Band20M));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One sequence for the whole station: a number taken on one band is not
|
||||||
|
/// sent again on another.
|
||||||
|
[Fact]
|
||||||
|
public void OneSequenceCountsAcrossTheBands()
|
||||||
|
{
|
||||||
|
SerialNumberPool pool = new();
|
||||||
|
pool.Take(Bands.Band20M);
|
||||||
|
pool.Take(Bands.Band20M);
|
||||||
|
|
||||||
|
Assert.Equal(3, pool.Next(Bands.Band40M));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CQ WPX multi-two and multi-multi count per band, so each band starts
|
||||||
|
/// again at one.
|
||||||
|
[Fact]
|
||||||
|
public void ByBandEachBandHasItsOwnSequence()
|
||||||
|
{
|
||||||
|
SerialNumberPool pool = new() { ByBand = true };
|
||||||
|
pool.Take(Bands.Band20M);
|
||||||
|
pool.Take(Bands.Band20M);
|
||||||
|
|
||||||
|
Assert.Equal(1, pool.Next(Bands.Band40M));
|
||||||
|
Assert.Equal(3, pool.Next(Bands.Band20M));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The point of the pool: the other station says which number it has taken
|
||||||
|
/// before it works the contact, so this one counts past it.
|
||||||
|
[Fact]
|
||||||
|
public void ANumberAnotherStationTookIsNotSentAgain()
|
||||||
|
{
|
||||||
|
SerialNumberPool pool = new();
|
||||||
|
pool.Take(Bands.Band20M);
|
||||||
|
|
||||||
|
Assert.True(pool.Note(2, Bands.Band40M));
|
||||||
|
Assert.Equal(3, pool.Next(Bands.Band20M));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A station that has fallen behind sends a number this one has used. The
|
||||||
|
/// clash is reported so the caller answers with the whole pool.
|
||||||
|
[Fact]
|
||||||
|
public void ANumberAlreadyUsedIsRefused()
|
||||||
|
{
|
||||||
|
SerialNumberPool pool = new();
|
||||||
|
pool.Take(Bands.Band20M);
|
||||||
|
pool.Take(Bands.Band20M);
|
||||||
|
|
||||||
|
Assert.False(pool.Note(1, Bands.Band40M));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same number back from the station it went to is the message going
|
||||||
|
/// round the entry, not two stations on one number.
|
||||||
|
[Fact]
|
||||||
|
public void TheNumberThisStationTookComingBackIsNoClash()
|
||||||
|
{
|
||||||
|
SerialNumberPool pool = new();
|
||||||
|
int mine = pool.Take(Bands.Band20M);
|
||||||
|
|
||||||
|
Assert.True(pool.Note(mine, Bands.Band20M));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ByBandAClashIsPerBand()
|
||||||
|
{
|
||||||
|
SerialNumberPool pool = new() { ByBand = true };
|
||||||
|
pool.Take(Bands.Band20M);
|
||||||
|
|
||||||
|
Assert.True(pool.Note(1, Bands.Band40M));
|
||||||
|
Assert.False(pool.Note(1, Bands.Band20M));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole pool from another station: the higher of the two is kept for
|
||||||
|
/// each band, so a station that was away cannot pull the numbers down.
|
||||||
|
[Fact]
|
||||||
|
public void MergingKeepsTheHigherNumberOfEachBand()
|
||||||
|
{
|
||||||
|
SerialNumberPool pool = new() { ByBand = true };
|
||||||
|
pool.Take(Bands.Band20M);
|
||||||
|
IReadOnlyList<int> theirs = new SerialNumberPool { ByBand = true }.Snapshot();
|
||||||
|
|
||||||
|
pool.Merge([.. theirs.Select((_, at) => at == N1mmBandIndex.Of(Bands.Band40M) ? 7 : 0)]);
|
||||||
|
|
||||||
|
Assert.Equal(2, pool.Next(Bands.Band20M));
|
||||||
|
Assert.Equal(8, pool.Next(Bands.Band40M));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheLogSaysWhereTheNumbersHaveGot()
|
||||||
|
{
|
||||||
|
SerialNumberPool pool = new();
|
||||||
|
|
||||||
|
pool.SeedFrom([Worked(Bands.Band20M, 4), Worked(Bands.Band40M, 9)]);
|
||||||
|
|
||||||
|
Assert.Equal(10, pool.Next(Bands.Band20M));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TakingANumberTellsTheOtherStations()
|
||||||
|
{
|
||||||
|
SerialNumberPool pool = new();
|
||||||
|
List<ReservedNumber> taken = [];
|
||||||
|
pool.Reserved += (_, number) => taken.Add(number);
|
||||||
|
|
||||||
|
pool.Take(Bands.Band20M);
|
||||||
|
|
||||||
|
Assert.Equal([new ReservedNumber(1, Bands.Band20M)], taken);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user