Tracing key presses with a busy feed showed the Check window rebuilding the distinct call lists of the log, the call history and every spot, and all five columns of controls, on each press. - ContestLog.Revision goes up with every change to the contacts. The Check sources keep each call list until its source changes. - The Check window builds a column again only when what it shows differs. UI thread time while typing with eight windows open went from 14.6% to 9.3%. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
389 lines
14 KiB
C#
389 lines
14 KiB
C#
using Nonemm.Core;
|
|
using Nonemm.Core.Calls;
|
|
using Nonemm.Core.Country;
|
|
|
|
namespace Nonemm.Contests;
|
|
|
|
/// The contacts of one contest, with the indexes that answer "worked before?"
|
|
/// and "new multiplier?" while the operator types.
|
|
public sealed class ContestLog
|
|
{
|
|
private readonly Contest contest;
|
|
private readonly StationInfo me;
|
|
private readonly CountryFile? countries;
|
|
private readonly List<Qso> qsos = [];
|
|
private readonly Dictionary<string, DateTime> workedKeys = new(StringComparer.Ordinal);
|
|
|
|
/// How many QTC lines have gone with each station, either way. It is kept
|
|
/// here rather than counted when a station is judged, because the bandmap
|
|
/// judges every spot it holds on every pass.
|
|
private readonly Dictionary<string, int> trafficWith = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly HashSet<string> claimedMultipliers = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<ScoreKey, ScoreTally> rows = [];
|
|
private ScoreTally tally = new();
|
|
|
|
/// `Judge` is called from a background thread by the windows that judge
|
|
/// every spot, while the UI thread adds and edits contacts. Both take this
|
|
/// lock around the indexes. The lock can be entered again by the thread
|
|
/// that holds it, because a rebuild judges each contact.
|
|
private readonly Lock guard = new();
|
|
|
|
public ContestLog(
|
|
Contest contest,
|
|
StationInfo me,
|
|
CountryFile? countries,
|
|
ContestEntry? entry = null)
|
|
{
|
|
this.contest = contest;
|
|
this.me = me;
|
|
this.countries = countries;
|
|
Entry = entry ?? new ContestEntry();
|
|
}
|
|
|
|
/// The categories and sent exchange of the running entry. The operator can
|
|
/// change them while the contest is open, and the score follows.
|
|
public ContestEntry Entry { get; set; }
|
|
|
|
/// The call history file, for a contest that counts a multiplier from it.
|
|
/// The session sets it when the operator loads one.
|
|
public CallHistory History { get; set; } = CallHistory.Empty;
|
|
|
|
public Contest Contest => contest;
|
|
|
|
/// Goes up with every change to the contacts, so something worked out
|
|
/// from the log can be kept until this changes.
|
|
public int Revision { get; private set; }
|
|
|
|
public IReadOnlyList<Qso> Qsos => qsos;
|
|
|
|
/// The contacts alone. In WAE the log also holds QTC traffic, and a QTC
|
|
/// row is not a contact: its report columns hold the time and callsign of
|
|
/// the contact being reported. Anything that writes contacts out — the
|
|
/// ADIF export — takes these rather than every row.
|
|
public IEnumerable<Qso> Contacts => qsos.Where(contest.IsContact);
|
|
|
|
public ScoreTally Tally => tally;
|
|
|
|
/// The same score split the way N1MM's score window splits it: a row per
|
|
/// band, and per kind of row as well where the contest has kinds. The
|
|
/// rows add up to `Tally`.
|
|
public IReadOnlyDictionary<ScoreKey, ScoreTally> Rows => rows;
|
|
|
|
public int TotalScore => contest.TotalScore(tally, Entry);
|
|
|
|
/// How many QTC lines have already gone with this station, either way.
|
|
public int TrafficWith(string call) =>
|
|
trafficWith.TryGetValue(call.Trim(), out int count) ? count : 0;
|
|
|
|
/// What logging this contact right now would do, without logging it.
|
|
public Verdict Judge(Qso candidate)
|
|
{
|
|
lock (guard)
|
|
{
|
|
return JudgeLocked(candidate);
|
|
}
|
|
}
|
|
|
|
private Verdict JudgeLocked(Qso candidate)
|
|
{
|
|
if (!candidate.IsClaimed)
|
|
{
|
|
return new Verdict(false, 0, []);
|
|
}
|
|
QsoContext context = ContextFor(candidate);
|
|
if (!contest.IsContact(candidate))
|
|
{
|
|
return new Verdict(false, contest.PointsFor(context), []);
|
|
}
|
|
int trafficLeft = contest.TrafficLeft(context, TrafficWith(candidate.Call.Text));
|
|
if (IsWorked(candidate))
|
|
{
|
|
return Verdict.Dupe with { TrafficLeft = trafficLeft };
|
|
}
|
|
List<Multiplier> newOnes = [];
|
|
foreach (Multiplier multiplier in contest.MultipliersFor(context))
|
|
{
|
|
if (!claimedMultipliers.Contains(MultiplierKey(multiplier, candidate)))
|
|
{
|
|
newOnes.Add(multiplier);
|
|
}
|
|
}
|
|
// a contest can pay a bonus for a new multiplier, so the flags have to
|
|
// be set before the points are worked out
|
|
QsoContext scored = ContextFor(ApplyMultiplierFlags(candidate, newOnes));
|
|
return new Verdict(
|
|
false,
|
|
IsContestMode(candidate) ? contest.PointsFor(scored) : 0,
|
|
newOnes,
|
|
trafficLeft);
|
|
}
|
|
|
|
/// Adds the contact and returns it with points and multiplier flags filled in.
|
|
public Qso Add(Qso qso)
|
|
{
|
|
lock (guard)
|
|
{
|
|
Verdict verdict = Judge(qso);
|
|
Qso scored = ApplyVerdict(qso, verdict);
|
|
qsos.Add(scored);
|
|
Index(scored, verdict);
|
|
Revision++;
|
|
return scored;
|
|
}
|
|
}
|
|
|
|
/// Loads contacts read back from storage. Points and multiplier flags in
|
|
/// the stored rows are ignored and worked out again from the rules.
|
|
public void Restore(IEnumerable<Qso> stored)
|
|
{
|
|
lock (guard)
|
|
{
|
|
qsos.AddRange(stored);
|
|
Rebuild();
|
|
}
|
|
}
|
|
|
|
/// Adds a contact that may be older than ones already logged, which is what
|
|
/// a contact from another station of the entry can be, and scores the log
|
|
/// again: the earlier of two contacts is the one that claims a multiplier.
|
|
public void Insert(Qso qso)
|
|
{
|
|
lock (guard)
|
|
{
|
|
qsos.Add(qso);
|
|
Rebuild();
|
|
}
|
|
}
|
|
|
|
/// `Insert` for several contacts, scoring the log once instead of once per
|
|
/// contact. A resync sends a few hundred in a burst.
|
|
public void InsertRange(IEnumerable<Qso> arrived)
|
|
{
|
|
lock (guard)
|
|
{
|
|
qsos.AddRange(arrived);
|
|
Rebuild();
|
|
}
|
|
}
|
|
|
|
public void Remove(string id)
|
|
{
|
|
lock (guard)
|
|
{
|
|
qsos.RemoveAll(q => q.Id == id);
|
|
Rebuild();
|
|
}
|
|
}
|
|
|
|
/// `Remove` for several contacts, scoring the log once.
|
|
public void RemoveRange(IReadOnlyCollection<string> ids)
|
|
{
|
|
HashSet<string> gone = [.. ids];
|
|
lock (guard)
|
|
{
|
|
qsos.RemoveAll(q => gone.Contains(q.Id));
|
|
Rebuild();
|
|
}
|
|
}
|
|
|
|
public void Replace(Qso qso)
|
|
{
|
|
lock (guard)
|
|
{
|
|
int at = qsos.FindIndex(q => q.Id == qso.Id);
|
|
if (at < 0)
|
|
{
|
|
throw new InvalidOperationException($"no contact with id {qso.Id} in the log");
|
|
}
|
|
qsos[at] = qso;
|
|
Rebuild();
|
|
}
|
|
}
|
|
|
|
/// A station worked before, under the contest's dupe rule. A contest with
|
|
/// a dupe window — N1MM's `DupeQSOMinutesAgo` — lets the same station be
|
|
/// worked again once the window has passed.
|
|
public bool IsWorked(Qso candidate)
|
|
{
|
|
if (contest.DupeScope == DupeScope.Never || !contest.IsContact(candidate))
|
|
{
|
|
return false;
|
|
}
|
|
if (!workedKeys.TryGetValue(DupeKey(candidate), out DateTime last))
|
|
{
|
|
return false;
|
|
}
|
|
return contest.DupeWindow is not { } window
|
|
|| (candidate.TimestampUtc - last).Duration() < window;
|
|
}
|
|
|
|
/// Every contact with this call, newest first.
|
|
public IReadOnlyList<Qso> WorkedBefore(string call) =>
|
|
qsos.Where(q => string.Equals(q.Call.Text, call, StringComparison.OrdinalIgnoreCase))
|
|
.OrderByDescending(q => q.TimestampUtc)
|
|
.ToList();
|
|
|
|
/// A contact in a mode the contest does not run — a tuning carrier logged
|
|
/// by accident, or a CW contact in an SSB contest — scores nothing. It
|
|
/// still brings its multiplier, which is what N1MM logs. A contest with no
|
|
/// mode of its own takes every mode.
|
|
private bool IsContestMode(Qso qso) =>
|
|
contest.Modes.Count == 0 || contest.Modes.Contains(qso.Mode.Category);
|
|
|
|
/// A maritime or aeronautical mobile belongs to no country, and the
|
|
/// contest still has to know which continent it is working, so the call it
|
|
/// is signing from is looked up when the call as sent places nowhere. What
|
|
/// that country counts for is the contest's decision.
|
|
private QsoContext ContextFor(Qso qso) =>
|
|
new(qso, countries?.Find(qso.Call) ?? countries?.Find(qso.Call.Station), me, Entry)
|
|
{
|
|
History = History,
|
|
};
|
|
|
|
private Qso ApplyVerdict(Qso qso, Verdict verdict) =>
|
|
ApplyMultiplierFlags(qso, verdict.NewMultipliers) with
|
|
{
|
|
Points = verdict.Points,
|
|
ContactType = ContactTypeOf(qso),
|
|
};
|
|
|
|
/// N1MM's contact type column, which it writes in `Contact.IsDupe`: `D`
|
|
/// for a station already worked, `N` for one this entry may not work at
|
|
/// all, and empty for an ordinary contact. Every contest is marked the
|
|
/// same way; what changes between them is what counts as a dupe and which
|
|
/// stations are workable, and both of those come from the contest here as
|
|
/// they do there.
|
|
///
|
|
/// Traffic keeps an empty column, which is what N1MM's QTC window writes:
|
|
/// its `IsDupe` only marks a row while the program is logging contacts.
|
|
private string ContactTypeOf(Qso qso)
|
|
{
|
|
if (!contest.IsContact(qso))
|
|
{
|
|
return "";
|
|
}
|
|
// a mark that came with the contact stands: N1MM marks a contact when
|
|
// it logs or edits it and never clears the mark in a pass over the
|
|
// log, so a row that arrived from another station or from an N1MM
|
|
// database keeps what it was given
|
|
if (IsWorked(qso) || qso.ContactType.Equals("D", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "D";
|
|
}
|
|
// N1MM adds our own callsign to whatever the contest says
|
|
bool workable = contest.IsWorkable(ContextFor(qso))
|
|
&& !qso.Call.Text.Equals(me.Callsign, StringComparison.OrdinalIgnoreCase);
|
|
return workable ? "" : "N";
|
|
}
|
|
|
|
private static Qso ApplyMultiplierFlags(Qso qso, IReadOnlyList<Multiplier> claimed) => qso with
|
|
{
|
|
IsMultiplier1 = claimed.Any(m => m.Index == 1),
|
|
IsMultiplier2 = claimed.Any(m => m.Index == 2),
|
|
IsMultiplier3 = claimed.Any(m => m.Index == 3),
|
|
};
|
|
|
|
/// Adds the contact to the score and to the indexes that answer "worked
|
|
/// before?" and "new multiplier?". An unclaimed contact enters neither:
|
|
/// N1MM scores it as zero and leaves it out of both searches, so it takes
|
|
/// nothing away from a later contact with the same call or multiplier. A
|
|
/// contact marked as a dupe is the other way round — N1MM searches for
|
|
/// dupes and multipliers among it, and leaves it out of the score alone.
|
|
private void Index(Qso qso, Verdict verdict)
|
|
{
|
|
if (!qso.IsClaimed)
|
|
{
|
|
return;
|
|
}
|
|
bool counts = qso.IsInScore;
|
|
ScoreTally row = RowFor(qso);
|
|
if (counts)
|
|
{
|
|
tally.AddQso(verdict.Points);
|
|
row.AddQso(verdict.Points);
|
|
}
|
|
if (!contest.IsContact(qso))
|
|
{
|
|
string station = qso.Call.Text.Trim();
|
|
trafficWith[station] = trafficWith.GetValueOrDefault(station) + 1;
|
|
return;
|
|
}
|
|
workedKeys[DupeKey(qso)] = qso.TimestampUtc;
|
|
foreach (Multiplier multiplier in verdict.NewMultipliers)
|
|
{
|
|
claimedMultipliers.Add(MultiplierKey(multiplier, qso));
|
|
if (counts)
|
|
{
|
|
tally.AddMultiplier(multiplier);
|
|
row.AddMultiplier(multiplier);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Scores the whole log again. Nothing here needs it — every change
|
|
/// rescores what it touches — but the stored points and multiplier flags
|
|
/// are written from this, and a caller that is about to write them asks for
|
|
/// a pass first.
|
|
public void Rescore()
|
|
{
|
|
lock (guard)
|
|
{
|
|
Rebuild();
|
|
}
|
|
}
|
|
|
|
/// Rescores the whole log. A removed or edited contact can hand its
|
|
/// multiplier to a later one, which only a full pass gets right.
|
|
private ScoreTally RowFor(Qso qso)
|
|
{
|
|
ScoreKey key = new(qso.Band?.Name ?? "", contest.RowKindFor(qso));
|
|
if (!rows.TryGetValue(key, out ScoreTally? row))
|
|
{
|
|
row = new ScoreTally();
|
|
rows[key] = row;
|
|
}
|
|
return row;
|
|
}
|
|
|
|
private void Rebuild()
|
|
{
|
|
Revision++;
|
|
workedKeys.Clear();
|
|
trafficWith.Clear();
|
|
claimedMultipliers.Clear();
|
|
rows.Clear();
|
|
tally = new ScoreTally();
|
|
List<Qso> ordered = qsos.OrderBy(q => q.TimestampUtc).ToList();
|
|
qsos.Clear();
|
|
foreach (Qso qso in ordered)
|
|
{
|
|
Verdict verdict = Judge(qso);
|
|
Qso scored = ApplyVerdict(qso, verdict);
|
|
qsos.Add(scored);
|
|
Index(scored, verdict);
|
|
}
|
|
}
|
|
|
|
private string DupeKey(Qso qso)
|
|
{
|
|
string call = qso.Call.Text.ToUpperInvariant();
|
|
string extra = contest.ExtraDupeKey(qso);
|
|
string key = contest.DupeScope switch
|
|
{
|
|
DupeScope.Once => call,
|
|
DupeScope.PerBand => $"{call}|{qso.Band?.Name}",
|
|
DupeScope.PerMode => $"{call}|{qso.Mode.Category}",
|
|
DupeScope.PerBandAndMode => $"{call}|{qso.Band?.Name}|{qso.Mode.Category}",
|
|
_ => call,
|
|
};
|
|
// a station worked in an earlier session may be worked again
|
|
key = $"{key}|{contest.SessionOf(qso.TimestampUtc)}";
|
|
return extra.Length == 0 ? key : $"{key}|{extra}";
|
|
}
|
|
|
|
private string MultiplierKey(Multiplier multiplier, Qso qso) =>
|
|
contest.ResetsMultipliersEachSession
|
|
? $"{multiplier.Index}|{multiplier.Value}|{multiplier.Scope}|{contest.SessionOf(qso.TimestampUtc)}"
|
|
: $"{multiplier.Index}|{multiplier.Value}|{multiplier.Scope}";
|
|
}
|