Files
Nonemm/src/Nonemm.Contests/ContestLog.cs
T
erikbrockoandClaude Sonnet 5.5 085584a281 Fix the crash on changing network settings, and apply resynced contacts in batches
ApplyNetworkSettings disposed the station link without clearing it, so
ApplyStationLinkSettings disposed it a second time and threw. Only the
second function touches the link now, and StationLink.Dispose can be
called twice.

Contacts from the network are queued and applied together on the window
thread. Each one used to rescore the whole log and redraw the log window,
which kept the window thread busy for minutes during a resync.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:26:03 +00:00

342 lines
13 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();
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;
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)
{
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)
{
Verdict verdict = Judge(qso);
Qso scored = ApplyVerdict(qso, verdict);
qsos.Add(scored);
Index(scored, verdict);
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)
{
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)
{
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)
{
qsos.AddRange(arrived);
Rebuild();
}
public void Remove(string id)
{
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];
qsos.RemoveAll(q => gone.Contains(q.Id));
Rebuild();
}
public void Replace(Qso qso)
{
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() => 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()
{
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}";
}