N1MM's format, so a file published for a contest is read as it is: # comments, !!Order!! naming the columns of the lines after it, semicolons in preference to commas so user text can hold a comma, and a default column order for a file that names none. The directives that change what is stored are acted on; !!Order!! can appear again part way through, because these files are usually several files stuck together. Leaving the callsign box fills what the file knows into the exchange boxes that are still empty. A box with something in it is left alone: what the station actually sends beats what somebody published months ago. The zone comes from the CqZone or the ITUZone column depending on which the contest asks for, and a file with no Sect falls back to State. The check window's two missing columns are in. History lists matching calls from the file. Exchange is not about callsigns at all: it offers the values the exchange box with the cursor in it can hold, which is what N1MM's exchange pane does — it searches the contest's list of valid exchanges, not the log. CheckCandidate.Call is now Text, because an exchange value is not a call. The section-validating directives are read and passed over rather than acted on. Acting on them means holding N1MM's section lists and its rules about retired sections, and getting that wrong throws away good data. Running it caught what the unit tests could not: the exchange was filled and scored but the boxes on screen still looked empty, because nothing resynced them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
128 lines
4.1 KiB
C#
128 lines
4.1 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 `RadioPosition`, 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);
|
|
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; } = "";
|
|
|
|
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(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;
|
|
}
|
|
|
|
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);
|
|
|
|
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;
|
|
}
|
|
|
|
private int NextSentNumber() =>
|
|
Log.Qsos.Count == 0 ? 1 : Log.Qsos.Max(q => q.SentNumber) + 1;
|
|
}
|