Add the core, contest, storage and format layers
Frequencies, bands, modes, callsigns, grid squares and the country file live in Nonemm.Core. Nonemm.Contests holds the scoring engine and CQ WW and CQ WPX. Nonemm.Storage writes N1MM's DXLOG schema, and Nonemm.Formats writes Cabrillo 3.0 and reads and writes ADIF. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
using Nonemm.Core;
|
||||
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 HashSet<string> workedKeys = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> claimedMultipliers = new(StringComparer.Ordinal);
|
||||
private ScoreTally tally = new();
|
||||
|
||||
public ContestLog(Contest contest, StationInfo me, CountryFile? countries)
|
||||
{
|
||||
this.contest = contest;
|
||||
this.me = me;
|
||||
this.countries = countries;
|
||||
}
|
||||
|
||||
public Contest Contest => contest;
|
||||
|
||||
public IReadOnlyList<Qso> Qsos => qsos;
|
||||
|
||||
public ScoreTally Tally => tally;
|
||||
|
||||
public int TotalScore => contest.TotalScore(tally);
|
||||
|
||||
/// What logging this contact right now would do, without logging it.
|
||||
public Verdict Judge(Qso candidate)
|
||||
{
|
||||
QsoContext context = ContextFor(candidate);
|
||||
if (workedKeys.Contains(DupeKey(candidate)))
|
||||
{
|
||||
return Verdict.Dupe;
|
||||
}
|
||||
List<Multiplier> newOnes = [];
|
||||
foreach (Multiplier multiplier in contest.MultipliersFor(context))
|
||||
{
|
||||
if (!claimedMultipliers.Contains(MultiplierKey(multiplier)))
|
||||
{
|
||||
newOnes.Add(multiplier);
|
||||
}
|
||||
}
|
||||
return new Verdict(false, contest.PointsFor(context), 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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public bool IsWorked(Qso candidate) => workedKeys.Contains(DupeKey(candidate));
|
||||
|
||||
/// 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();
|
||||
|
||||
private QsoContext ContextFor(Qso qso) =>
|
||||
new(qso, countries?.Find(qso.Call), me);
|
||||
|
||||
private Qso ApplyVerdict(Qso qso, Verdict verdict) => qso with
|
||||
{
|
||||
Points = verdict.Points,
|
||||
IsMultiplier1 = verdict.NewMultipliers.Any(m => m.Index == 1),
|
||||
IsMultiplier2 = verdict.NewMultipliers.Any(m => m.Index == 2),
|
||||
IsMultiplier3 = verdict.NewMultipliers.Any(m => m.Index == 3),
|
||||
};
|
||||
|
||||
private void Index(Qso qso, Verdict verdict)
|
||||
{
|
||||
workedKeys.Add(DupeKey(qso));
|
||||
tally.AddQso(verdict.Points);
|
||||
foreach (Multiplier multiplier in verdict.NewMultipliers)
|
||||
{
|
||||
claimedMultipliers.Add(MultiplierKey(multiplier));
|
||||
tally.AddMultiplier(multiplier.Index);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 void Rebuild()
|
||||
{
|
||||
workedKeys.Clear();
|
||||
claimedMultipliers.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();
|
||||
return 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,
|
||||
};
|
||||
}
|
||||
|
||||
private static string MultiplierKey(Multiplier multiplier) =>
|
||||
$"{multiplier.Index}|{multiplier.Value}|{multiplier.Scope}";
|
||||
}
|
||||
Reference in New Issue
Block a user