The score summary now splits the way N1MM's does. `ContestLog` keeps a tally per band, and per kind of row where the contest has kinds, and the score window reads those instead of adding the rows up itself. Three things were wrong with the old sum: - The band rows counted a multiplier as one whatever the band. WAE weights it by band — `ScoreRow.AddContact` adds four on 3.5, three on 7 and two on 14, 21 and 28 — so the band rows did not add up to the total. - The band rows counted contacts left out of the claimed score. - There was no split by kind. `CWAE.LogInfo` and `ScoreClass.Description` give WAE a `Q/QTC` column and a row per band and kind, so contacts, traffic sent and traffic received are read apart. The Cabrillo writer now pads the record name to seven characters, which is what `ConvertCabrilloStructStdToString` does and why: `X-QSO:` is six characters and a space, so every record name puts the frequency in the same column. Nonemm wrote five, and the `X-QSO:` lines added last commit did not line up with the rest. WAE's own layouts follow `CWAE.CabrilloString`: three spaces after the sent serial rather than the one every other column gets, and the transmitter column only for a multi-operator entry. `QtcTraffic.ToSend` no longer offers a contact marked as a dupe, which is what `DBSupport.QTCRecordsetCount` leaves out with `Trim(ContactType) = ''`. Checked by running the program against a WAE SSB log of six contacts and a series of ten received: the band rows read 4, 3, 2 and 2 and add up to the 11 in the total row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NZkFxHde1iifAQemFTHxf
270 lines
9.6 KiB
C#
270 lines
9.6 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);
|
|
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;
|
|
|
|
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);
|
|
|
|
/// 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), []);
|
|
}
|
|
if (IsWorked(candidate))
|
|
{
|
|
return Verdict.Dupe;
|
|
}
|
|
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);
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
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<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 static Qso ApplyVerdict(Qso qso, Verdict verdict) =>
|
|
ApplyMultiplierFlags(qso, verdict.NewMultipliers) with { Points = verdict.Points };
|
|
|
|
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))
|
|
{
|
|
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();
|
|
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}";
|
|
}
|