Each insert, delete and rescore update was its own commit to disk, so a resync of about a thousand QSOs froze the window thread. LogStore has a new Batch() scope that commits its writes together. ContestSession uses it in AddRangeFromNetwork, ClearFromNetwork and Rescore. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
330 lines
12 KiB
C#
330 lines
12 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);
|
|
Numbers = new SerialNumberPool { ByBand = contest.NumbersByBand(instance.Entry) };
|
|
SeedNumbers();
|
|
}
|
|
|
|
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; }
|
|
|
|
/// 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; } = "";
|
|
|
|
/// 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;
|
|
|
|
/// Starts the serial numbers again: the pool is filled from the log, so the
|
|
/// next number is the highest number in it plus one. That is what N1MM's
|
|
/// Reset Serial Numbers at all Stations does, at every station of the
|
|
/// entry, and it leaves the contacts already logged with the numbers they
|
|
/// were sent with.
|
|
public void RestartNumbers()
|
|
{
|
|
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;
|
|
|
|
/// 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);
|
|
}
|
|
RecordNumber(stored);
|
|
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();
|
|
using (store.Batch())
|
|
{
|
|
foreach (Qso qso in gone)
|
|
{
|
|
store.Delete(qso.Id);
|
|
}
|
|
}
|
|
if (gone.Count > 0)
|
|
{
|
|
Log.RemoveRange([.. gone.Select(q => q.Id)]);
|
|
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;
|
|
using (store.Batch())
|
|
{
|
|
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;
|
|
}
|
|
Qso arrived = store.Add(FromNetwork(qso));
|
|
Log.Insert(arrived);
|
|
RecordNumber(arrived);
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
return true;
|
|
}
|
|
|
|
/// `AddFromNetwork` for a burst of contacts, which is what a resync sends.
|
|
/// The log is scored and `Changed` is raised once for the lot, because
|
|
/// doing either per contact keeps the window thread busy for minutes on a
|
|
/// log of a few thousand. Returns how many were new.
|
|
public int AddRangeFromNetwork(IEnumerable<Qso> arriving)
|
|
{
|
|
HashSet<string> held = [.. Log.Qsos.Select(q => q.Id)];
|
|
List<Qso> stored = [];
|
|
using (store.Batch())
|
|
{
|
|
foreach (Qso qso in arriving)
|
|
{
|
|
if (held.Add(qso.Id))
|
|
{
|
|
stored.Add(store.Add(FromNetwork(qso)));
|
|
}
|
|
}
|
|
}
|
|
if (stored.Count == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
Log.InsertRange(stored);
|
|
foreach (Qso qso in stored)
|
|
{
|
|
RecordNumber(qso);
|
|
}
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
return stored.Count;
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
/// 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;
|
|
Numbers.SeedFrom(counted.Where(Contest.IsContact));
|
|
}
|
|
|
|
/// The sent number of a contact, and nothing else. A WAE QTC row carries
|
|
/// the serial of the contact it reports in the same column, and that
|
|
/// number was sent by the station being reported rather than by us, so
|
|
/// taking it would push our own numbers up. N1MM leaves the QTC rows out
|
|
/// of the same query, in `QSONrsByBand`.
|
|
private void RecordNumber(Qso qso)
|
|
{
|
|
if (Contest.IsContact(qso))
|
|
{
|
|
Numbers.Record(qso.SentNumber, qso.Band);
|
|
}
|
|
}
|
|
}
|