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:
2026-09-10 11:56:49 +00:00
co-authored by Claude Opus 5
parent 33c7631e17
commit 7ba0b44fc1
21 changed files with 677 additions and 40 deletions
+29 -13
View File
@@ -33,7 +33,8 @@ public sealed class ContestSession
Log = new ContestLog(contest, me, countries, instance.Entry) { History = History };
Log.Restore(store.Qsos(instance.ContestNumber));
Editor = new QsoEditor(contest, me, countries);
SentNumber = NextSentNumber();
Numbers = new SerialNumberPool { ByBand = contest.NumbersByBand(instance.Entry) };
SeedNumbers();
}
public Contest Contest { get; }
@@ -51,8 +52,11 @@ public sealed class ContestSession
/// What was published about the stations in this contest before it started.
public CallHistory History { get; }
/// Serial numbers count up across the station, not per radio.
public int SentNumber { get; private set; }
/// The serial numbers the whole entry has taken, one highest per band. A
/// 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; } = "";
@@ -74,10 +78,22 @@ public sealed class ContestSession
set
{
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<Qso>? Logged;
@@ -99,7 +115,7 @@ public sealed class ContestSession
{
Log.Replace(stored);
}
SentNumber = NextSentNumber();
Numbers.Record(stored.SentNumber, stored.Band);
Logged?.Invoke(this, stored);
Changed?.Invoke(this, EventArgs.Empty);
return stored;
@@ -189,8 +205,9 @@ public sealed class ContestSession
{
return false;
}
Log.Insert(store.Add(FromNetwork(qso)));
SentNumber = NextSentNumber();
Qso arrived = store.Add(FromNetwork(qso));
Log.Insert(arrived);
Numbers.Record(arrived.SentNumber, arrived.Band);
Changed?.Invoke(this, EventArgs.Empty);
return true;
}
@@ -252,16 +269,15 @@ public sealed class ContestSession
return edit;
}
/// The next serial number to send. A contest whose `ResetSNEverySession`
/// is set starts over in each session, so only the contacts of the session
/// we are in count.
private int NextSentNumber()
/// Fills the pool from the numbers the log already holds. A contest whose
/// `ResetSNEverySession` is set starts over in each session, so only the
/// contacts of the session we are in count.
private void SeedNumbers()
{
IEnumerable<Qso> counted = Contest.ResetsSerialNumbersEachSession
? Log.Qsos.Where(q =>
Contest.SessionOf(q.TimestampUtc) == Contest.SessionOf(DateTime.UtcNow))
: Log.Qsos;
counted = counted.Where(q => q.TimestampUtc >= SerialNumbersFrom);
return counted.Select(q => q.SentNumber).DefaultIfEmpty(0).Max() + 1;
Numbers.SeedFrom(counted.Where(q => q.TimestampUtc >= SerialNumbersFrom));
}
}
+18 -1
View File
@@ -16,7 +16,24 @@ public sealed class EntryFields
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.
public int Focus { get; private set; }
+38 -1
View File
@@ -21,6 +21,8 @@ public sealed class OperatingPosition
// contact is logged, so the other radio is not left showing the number
// that has just gone
Session.Logged += (_, _) => FillSentExchange();
Session.NumbersRestarted += (_, _) => LetTheNumberGo();
Entry.CallStarted += (_, _) => ReserveNumber();
}
public ContestSession Session { get; }
@@ -33,6 +35,10 @@ public sealed class OperatingPosition
/// With no radio connected this stays where it was last typed.
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.
public Frequency TransmitFrequency { get; private set; } = Frequency.Zero;
@@ -64,7 +70,35 @@ public sealed class OperatingPosition
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)
{
@@ -229,6 +263,9 @@ public sealed class OperatingPosition
}
StampSentTime();
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();
FillSentExchange();
OtherName = "";
+7
View File
@@ -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);
+99
View File
@@ -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();
}