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 qsos = []; private readonly Dictionary 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 trafficWith = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet claimedMultipliers = new(StringComparer.Ordinal); private readonly Dictionary 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 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 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 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 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 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(); } public void Remove(string id) { qsos.RemoveAll(q => q.Id == 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 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 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 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}"; }