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? 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? Edited; public event EventHandler? 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 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; } Qso arrived = store.Add(FromNetwork(qso)); Log.Insert(arrived); RecordNumber(arrived); 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 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 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); } } }