Seven tabs now, in N1MM's order, and the messages behind them. **Options** holds what there is something behind: Log QSOs at all Stations, which decides whether the contacts the others log are kept here; the message trace, moved off the Messages tab where N1MM does not keep it; and Run1/Run2, which used to be guessed from the radio number and is now the operator's choice, sent in every `STATUS` and every `XMIT`. The transmit interlocks stay out: this program does not block a transmitter, and a station that claimed it did would be waited on for nothing. **Call Stack Targets.** A callsign heard at one station and handed to the position that will work it — `STACKANOTHERCALL` with the call alone, `STACKCALL` with the contact behind it. The target is picked from the tab or from the station list, and what arrives goes on the call stack of the radio the operator is on, sorted by whether it is a multiplier here. **The master** is the station the entry follows for cluster spots and the time. `MASTER` names it to everybody, so the column agrees everywhere. **The clock.** `TIME` carries a station's clock and Force Time Sync sends it. N1MM sets the clock from it when it is running as an administrator and says how far out it is when it is not; this program always does the second, because setting the clock needs root and a contest logger has no business asking for it. Anything over two seconds is called out. **Serial numbers.** Reset Serial Numbers at all Stations sends `RESETQSONRS` and every station starts again at one. The contacts already logged keep the numbers they were sent with. **What the entry agrees on** is now checked rather than assumed: | What | How | |---|---| | the contest | `CONTESTNAME` as soon as a connection opens, and again in every `CHECKSUM` | | the country file | its issue date — the publisher's `=VER20260826` — rides in `STATUS` | | the computer name | longer than N1MM's fifteen characters is named at startup; a second computer answering to this one's name is named when its beacon arrives | | the station number | two stations on the same number are named when the second says so | `CountryFile` reads its issue date, which nothing had asked it for before. Left for later: the master relaying its cluster spots, and the shared serial-number pool where stations claim numbers in advance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
268 lines
9.3 KiB
C#
268 lines
9.3 KiB
C#
using Nonemm.Contests;
|
|
using Nonemm.Core;
|
|
using Nonemm.Core.Calls;
|
|
using Nonemm.Core.Country;
|
|
using Nonemm.Storage;
|
|
|
|
namespace Nonemm.Session;
|
|
|
|
/// The running contest: the log, the score and what happens when a contact is
|
|
/// logged. One of these per contest, however many radios the station has.
|
|
/// What the operator is typing lives in `OperatingPosition`, one per radio.
|
|
///
|
|
/// No UI framework is referenced here, so the behaviour an operator judges the
|
|
/// logger by is tested with plain unit tests.
|
|
public sealed class ContestSession
|
|
{
|
|
private readonly LogStore store;
|
|
|
|
public ContestSession(
|
|
LogStore store,
|
|
Contest contest,
|
|
ContestInstance instance,
|
|
StationInfo me,
|
|
CountryFile? countries,
|
|
CallHistory? history = null)
|
|
{
|
|
this.store = store;
|
|
Countries = countries;
|
|
History = history ?? CallHistory.Empty;
|
|
Contest = contest;
|
|
Instance = instance;
|
|
Me = me;
|
|
Log = new ContestLog(contest, me, countries, instance.Entry) { History = History };
|
|
Log.Restore(store.Qsos(instance.ContestNumber));
|
|
Editor = new QsoEditor(contest, me, countries);
|
|
SentNumber = NextSentNumber();
|
|
}
|
|
|
|
public Contest Contest { get; }
|
|
|
|
public ContestInstance Instance { get; }
|
|
|
|
public StationInfo Me { get; }
|
|
|
|
public ContestLog Log { get; }
|
|
|
|
public QsoEditor Editor { get; }
|
|
|
|
public CountryFile? Countries { get; }
|
|
|
|
/// 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; }
|
|
|
|
public string Operator { get; set; } = "";
|
|
|
|
/// The computer this station logs as, and which station of the entry it is.
|
|
/// N1MM stamps every contact with both — `NetBiosName` and
|
|
/// `NetworkedCompNr` — and a resync asks for the contacts of one computer
|
|
/// by name, so a contact logged without it belongs to no station.
|
|
public string StationName { get; set; } = "";
|
|
|
|
public int StationNumber { get; set; } = 1;
|
|
|
|
/// Contacts logged before this are not counted when the next serial number
|
|
/// is worked out, so Reset Serial Numbers starts them again at one. The
|
|
/// contacts already logged keep the numbers they were sent with. It lasts
|
|
/// as long as the program is open, the way N1MM's does.
|
|
public DateTime SerialNumbersFrom
|
|
{
|
|
get;
|
|
set
|
|
{
|
|
field = value;
|
|
SentNumber = NextSentNumber();
|
|
}
|
|
}
|
|
|
|
public event EventHandler? Changed;
|
|
|
|
public event EventHandler<Qso>? Logged;
|
|
|
|
/// A contact that was edited, with the call and time it had before. The
|
|
/// other stations need the old pair to find their copy of it.
|
|
public event EventHandler<QsoChange>? Edited;
|
|
|
|
public event EventHandler<Qso>? Deleted;
|
|
|
|
/// Scores the contact, writes it and hands back what was stored: the
|
|
/// timestamp can move by a second when another contact with the same call
|
|
/// already holds it.
|
|
public Qso Add(Qso qso)
|
|
{
|
|
Qso scored = Log.Add(Stamped(qso));
|
|
Qso stored = store.Add(scored);
|
|
if (stored.TimestampUtc != scored.TimestampUtc)
|
|
{
|
|
Log.Replace(stored);
|
|
}
|
|
SentNumber = NextSentNumber();
|
|
Logged?.Invoke(this, stored);
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
return stored;
|
|
}
|
|
|
|
/// Drops every contact one computer made inside a block of time, which is
|
|
/// what a resync asks for before it sends the block again. Returns how many
|
|
/// went.
|
|
public int ClearFromNetwork(string computerName, DateTime fromUtc, TimeSpan length)
|
|
{
|
|
List<Qso> gone = Log.Qsos
|
|
.Where(q =>
|
|
string.Equals(q.StationName, computerName, StringComparison.OrdinalIgnoreCase)
|
|
&& q.TimestampUtc >= fromUtc
|
|
&& q.TimestampUtc < fromUtc + length)
|
|
.ToList();
|
|
foreach (Qso qso in gone)
|
|
{
|
|
store.Delete(qso.Id);
|
|
Log.Remove(qso.Id);
|
|
}
|
|
if (gone.Count > 0)
|
|
{
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
return gone.Count;
|
|
}
|
|
|
|
/// Scores the log again and writes the points and multiplier flags of the
|
|
/// contacts from `fromUtc` on back to storage. Returns how many rows were
|
|
/// written.
|
|
///
|
|
/// The log in memory scores itself whenever it changes, so this is for the
|
|
/// database: N1MM reads the stored points and flags rather than working
|
|
/// them out, and a contact that arrived from another station after the one
|
|
/// that took a multiplier leaves those columns behind.
|
|
public int Rescore(DateTime fromUtc)
|
|
{
|
|
Log.Rescore();
|
|
int written = 0;
|
|
foreach (Qso qso in Log.Qsos.Where(q => q.TimestampUtc >= fromUtc))
|
|
{
|
|
store.Update(qso);
|
|
written++;
|
|
}
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
return written;
|
|
}
|
|
|
|
/// Whose contact this is. A contact that already names a station keeps it.
|
|
private Qso Stamped(Qso qso) =>
|
|
StationName.Length == 0 || qso.StationName.Length > 0
|
|
? qso
|
|
: qso with { StationName = StationName, NetworkedComputerNumber = StationNumber };
|
|
|
|
public void Delete(string id)
|
|
{
|
|
Qso? gone = Log.Qsos.FirstOrDefault(q => q.Id == id);
|
|
store.Delete(id);
|
|
Log.Remove(id);
|
|
if (gone is not null)
|
|
{
|
|
Deleted?.Invoke(this, gone);
|
|
}
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// Changes one field of a logged contact. A refused edit leaves the log
|
|
/// alone and the caller gets the reason back.
|
|
public QsoEdit Edit(string id, QsoField field, string text) =>
|
|
EditWith(id, before => Editor.Apply(before, field, text));
|
|
|
|
/// Replaces a logged contact with one the caller has already built and
|
|
/// checked. The edit contact dialog changes many fields at once and comes
|
|
/// in this way.
|
|
public void Update(Qso qso) => EditWith(qso.Id, _ => QsoEdit.Accept(qso));
|
|
|
|
public void NotifyChanged() => Changed?.Invoke(this, EventArgs.Empty);
|
|
|
|
/// A contact worked by another station of a multi-operator entry. It is
|
|
/// scored here from the rules rather than trusting what the sender worked
|
|
/// out, and it raises no `Logged`, so it does not go back out to the other
|
|
/// stations. False when we already hold it.
|
|
public bool AddFromNetwork(Qso qso)
|
|
{
|
|
if (Log.Qsos.Any(q => q.Id == qso.Id))
|
|
{
|
|
return false;
|
|
}
|
|
Log.Insert(store.Add(FromNetwork(qso)));
|
|
SentNumber = NextSentNumber();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
return true;
|
|
}
|
|
|
|
/// An edit made at another station. False when we do not hold the contact
|
|
/// it changes.
|
|
public bool ReplaceFromNetwork(Qso qso, string oldCall, DateTime oldTimestampUtc)
|
|
{
|
|
if (Find(qso.Id, oldCall, oldTimestampUtc) is not { } old)
|
|
{
|
|
return false;
|
|
}
|
|
Qso replacement = FromNetwork(qso) with { Id = old.Id };
|
|
store.Update(replacement);
|
|
Log.Replace(replacement);
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
return true;
|
|
}
|
|
|
|
/// A contact deleted at another station. False when we do not hold it.
|
|
public bool DeleteFromNetwork(string id, string call, DateTime timestampUtc)
|
|
{
|
|
if (Find(id, call, timestampUtc) is not { } gone)
|
|
{
|
|
return false;
|
|
}
|
|
store.Delete(gone.Id);
|
|
Log.Remove(gone.Id);
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
return true;
|
|
}
|
|
|
|
private Qso FromNetwork(Qso qso) => qso with
|
|
{
|
|
ContestNumber = Instance.ContestNumber,
|
|
IsOriginal = false,
|
|
};
|
|
|
|
/// N1MM keys a contact on its call and time, so a message from N1MM carries
|
|
/// no id we would recognise. Fall back to that pair when the id misses.
|
|
private Qso? Find(string id, string call, DateTime timestampUtc) =>
|
|
Log.Qsos.FirstOrDefault(q => id.Length > 0 && q.Id == id)
|
|
?? Log.Qsos.FirstOrDefault(q =>
|
|
string.Equals(q.Call.Text, call, StringComparison.OrdinalIgnoreCase)
|
|
&& q.TimestampUtc == timestampUtc);
|
|
|
|
private QsoEdit EditWith(string id, Func<Qso, QsoEdit> change)
|
|
{
|
|
Qso before = Log.Qsos.FirstOrDefault(q => q.Id == id)
|
|
?? throw new InvalidOperationException($"no contact with id {id} in the log");
|
|
QsoEdit edit = change(before);
|
|
if (edit.Result is not null)
|
|
{
|
|
store.Update(edit.Result);
|
|
Log.Replace(edit.Result);
|
|
Edited?.Invoke(this, new QsoChange(edit.Result, before.Call.Text, before.TimestampUtc));
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
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()
|
|
{
|
|
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;
|
|
}
|
|
}
|