Read the rest of what a .udc file says, and score what N1MM scores
Two pieces of work on the scoring engine. The .udc reader now takes N1MM's published scoring vocabulary: the full PointsPerContact condition set, the PointsMultBy family, PowerMult, BonusPoints, the multiplier sources and the settings that say which stations bring a multiplier in. MultMult is a weight rather than a switch, as in N1MM's ComputeScore. The entry categories and the sent exchange reach the rules in a new ContestEntry, so a contest can score by the power category it is entered in or by what this station sends. Then every contest in a real N1MM log was scored again from these rules and compared with the points and multiplier flags N1MM wrote. That found five bugs: ARRL DX and IARU read the exchange from the wrong column, a stored " " was read as a value rather than as empty, CQ WW RTTY's own Canadian area names were not counted, a maritime mobile scored nothing, and a contact in a mode the contest does not run scored as if it were in the contest. docs/n1mm-interop.md has the check and what still differs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
@@ -79,3 +79,50 @@ opened. The name carries the mode, so it beats whatever `ModeCategory` says.
|
||||
|
||||
Older N1MM versions wrote the CW running of CQ WW as plain `CQWW`. That name is
|
||||
still in the registry, so those logs open.
|
||||
|
||||
## Which column an exchange lands in
|
||||
|
||||
N1MM keeps the received exchange of ARRL DX, IARU and WAE in `Sect`, not in
|
||||
`Exchange1`, whatever the entry window calls the box. A contest that reads the
|
||||
wrong column scores every contact as if the operator had typed nothing, so the
|
||||
rule reads the column N1MM writes and the entry window points its box at the
|
||||
same place.
|
||||
|
||||
N1MM also writes a single space into a text column it has nothing for, so
|
||||
`Continent` comes back as `" "` rather than empty. Reading it as a value rather
|
||||
than as empty makes every contact look like another continent. The store trims
|
||||
what it reads.
|
||||
|
||||
## Scoring checked against a real log
|
||||
|
||||
`om5m.s3db` in the repo root is a station's own N1MM log: 78 contest instances
|
||||
from 2019 to 2026, 57,000 contacts, 28 contest names. Replaying it is how the
|
||||
scoring rules are checked — every contact is scored again from our rules and
|
||||
compared with the points and multiplier flags N1MM wrote.
|
||||
|
||||
26 of the 37 instances whose contest we have rules for now agree on every
|
||||
contact and on the totals. What the check found and fixed:
|
||||
|
||||
| What | Was |
|
||||
|---|---|
|
||||
| ARRL DX and IARU read the wrong column | no multipliers at all, and IARU scored every contact 1 point |
|
||||
| a stored `" "` was read as a value | 181 contacts in one log scored as another continent |
|
||||
| the CQ WW RTTY area list | `NF` and `LB` are that contest's own names for the Canadian areas, and were not counted |
|
||||
| ARRL DX province spellings | `QB`, `NF` and `LB` count as Quebec and as Newfoundland and Labrador |
|
||||
| a maritime mobile scored nothing | it counts for no country and still scores by continent |
|
||||
| a contact in a mode the contest does not run | scored as if it were in the contest; N1MM scores it zero and keeps its multiplier |
|
||||
|
||||
What still differs, and why:
|
||||
|
||||
- **The country file of the day.** A 2019 log holds calls that today's
|
||||
`wl_cty.dat` places elsewhere, and the multiplier moves to another contact
|
||||
with it. This is most of the difference in the 2019 CQ WW SSB log.
|
||||
- **N1MM counts a call it cannot place as a multiplier with an empty value**,
|
||||
and we do not. Same as the WAE difference already written down.
|
||||
- **N1MM counts `NF` and `LB` as two ARRL DX multipliers.** They are one
|
||||
province, and we count one.
|
||||
- **A maritime mobile brings no country multiplier here**, which is the
|
||||
published CQ WW rule; N1MM counts one for it.
|
||||
- **Stale cached points.** N1MM caches points in the row and does not always
|
||||
work them out again after a callsign is corrected, so a handful of rows hold
|
||||
a number that its own rules do not produce.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# What is not finished, and what has not been tested
|
||||
|
||||
Two separate lists. A feature can be finished and untested, or half-built and
|
||||
exercised every day. Written 2026-08-27.
|
||||
exercised every day. Written 2026-08-27, updated 2026-08-31.
|
||||
|
||||
## Never run against the real thing
|
||||
|
||||
@@ -107,10 +107,24 @@ needs QTC traffic, IOTA needs island references as multipliers, and CQ WW RTTY
|
||||
needed a third multiplier and its own points table because it shares a name
|
||||
with the CW and SSB running of the contest.
|
||||
|
||||
What `.udc` cannot say here yet: an exchange that differs by the other
|
||||
station's country (the OK/OM district against a serial number from everyone
|
||||
else), points that depend on a zone or a distance, and multiplier sources
|
||||
beyond country, prefix, zone, section, exchange, grid and continent.
|
||||
`.udc` settings that are still passed over. `CallHist` as a multiplier source,
|
||||
which takes the value from the call history file. `BonusPoints2`, which reads
|
||||
the bonus callsigns from a file. `MultiplierBands`, `MultWindowType` and
|
||||
`QsoErrorString`, which are about the windows rather than the score.
|
||||
`CabrilloString`, `CabrilloFormat` and `GenericPrintString`, so a contest whose
|
||||
Cabrillo line is not callsign, report and one exchange value comes out in the
|
||||
default shape. The session, off-time and band-change settings
|
||||
(`MultipleSessions`, `MinimumOffTime`, the `…BandChange…` family,
|
||||
`DupeQSOMinutesAgo`) are read by nothing, so a contest with periods is logged
|
||||
as one long session.
|
||||
|
||||
N1MM's `.udc` format has no way to say that the exchange itself differs by the
|
||||
other station's country, as the OK/OM DX contest asks a district from OK and OM
|
||||
stations and a serial number from everyone else. That one needs code, as it
|
||||
does in N1MM, and `Contest.SkipsField` is where it goes. A contest scored by
|
||||
what this station sends — YOTA counts the sent exchange — needs code too, and
|
||||
reads `QsoContext.Entry`, which carries the entry categories and the sent
|
||||
exchange.
|
||||
|
||||
## Known rough edges
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ public interface Contest
|
||||
|
||||
DupeScope DupeScope { get; }
|
||||
|
||||
/// One more field that makes a contact distinct, on top of the call, band
|
||||
/// and mode the dupe scope names. Empty for every contest that dupes on the
|
||||
/// callsign alone.
|
||||
string ExtraDupeKey(Qso qso) => "";
|
||||
|
||||
/// True when serial numbers count up across the whole contest rather than
|
||||
/// per band.
|
||||
bool HasSerialNumbers { get; }
|
||||
@@ -61,7 +66,9 @@ public interface Contest
|
||||
|
||||
IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso);
|
||||
|
||||
int TotalScore(ScoreTally tally);
|
||||
/// The claimed score. The entry is here because a contest can multiply
|
||||
/// the whole score by the power category the operator entered in.
|
||||
int TotalScore(ScoreTally tally, ContestEntry entry);
|
||||
|
||||
/// The exchange columns of this contact's Cabrillo line.
|
||||
CabrilloExchange CabrilloExchange(Qso qso, StationInfo me);
|
||||
|
||||
@@ -15,20 +15,29 @@ public sealed class ContestLog
|
||||
private readonly HashSet<string> claimedMultipliers = new(StringComparer.Ordinal);
|
||||
private ScoreTally tally = new();
|
||||
|
||||
public ContestLog(Contest contest, StationInfo me, CountryFile? countries)
|
||||
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; }
|
||||
|
||||
public Contest Contest => contest;
|
||||
|
||||
public IReadOnlyList<Qso> Qsos => qsos;
|
||||
|
||||
public ScoreTally Tally => tally;
|
||||
|
||||
public int TotalScore => contest.TotalScore(tally);
|
||||
public int TotalScore => contest.TotalScore(tally, Entry);
|
||||
|
||||
/// What logging this contact right now would do, without logging it.
|
||||
public Verdict Judge(Qso candidate)
|
||||
@@ -50,7 +59,10 @@ public sealed class ContestLog
|
||||
newOnes.Add(multiplier);
|
||||
}
|
||||
}
|
||||
return new Verdict(false, contest.PointsFor(context), newOnes);
|
||||
// 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.
|
||||
@@ -108,15 +120,28 @@ public sealed class ContestLog
|
||||
.OrderByDescending(q => q.TimestampUtc)
|
||||
.ToList();
|
||||
|
||||
private QsoContext ContextFor(Qso qso) =>
|
||||
new(qso, countries?.Find(qso.Call), me);
|
||||
/// 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);
|
||||
|
||||
private Qso ApplyVerdict(Qso qso, Verdict verdict) => qso with
|
||||
/// 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);
|
||||
|
||||
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
|
||||
{
|
||||
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),
|
||||
IsMultiplier1 = claimed.Any(m => m.Index == 1),
|
||||
IsMultiplier2 = claimed.Any(m => m.Index == 2),
|
||||
IsMultiplier3 = claimed.Any(m => m.Index == 3),
|
||||
};
|
||||
|
||||
private void Index(Qso qso, Verdict verdict)
|
||||
@@ -155,7 +180,8 @@ public sealed class ContestLog
|
||||
private string DupeKey(Qso qso)
|
||||
{
|
||||
string call = qso.Call.Text.ToUpperInvariant();
|
||||
return contest.DupeScope switch
|
||||
string extra = contest.ExtraDupeKey(qso);
|
||||
string key = contest.DupeScope switch
|
||||
{
|
||||
DupeScope.Once => call,
|
||||
DupeScope.PerBand => $"{call}|{qso.Band?.Name}",
|
||||
@@ -163,6 +189,7 @@ public sealed class ContestLog
|
||||
DupeScope.PerBandAndMode => $"{call}|{qso.Band?.Name}|{qso.Mode.Category}",
|
||||
_ => call,
|
||||
};
|
||||
return extra.Length == 0 ? key : $"{key}|{extra}";
|
||||
}
|
||||
|
||||
private static string MultiplierKey(Multiplier multiplier) =>
|
||||
|
||||
@@ -21,11 +21,50 @@ public static class StatesAndProvinces
|
||||
"NS", "QC", "ON", "MB", "SK", "AB", "BC", "NT", "NB", "NL", "PE", "YT", "NU",
|
||||
};
|
||||
|
||||
/// What ARRL DX counts for a DX station: the contiguous states, the District
|
||||
/// of Columbia, and the Canadian provinces and territories.
|
||||
/// The Canadian areas as the contests that split them name them:
|
||||
/// Newfoundland apart from Labrador, and the territories spelled out.
|
||||
public static readonly IReadOnlyList<string> SplitCanadianAreas =
|
||||
["NWT", "NF", "LB", "PEI"];
|
||||
|
||||
/// What ARRL DX counts for a DX station: the contiguous states, the
|
||||
/// District of Columbia, and the Canadian provinces and territories.
|
||||
public static readonly IReadOnlySet<string> ArrlDxMultipliers =
|
||||
new HashSet<string>(ContiguousStates.Concat(["DC"]).Concat(Provinces), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// The same province under another name. Canadian stations send the older
|
||||
/// abbreviations as often as the postal codes, and ARRL DX counts the
|
||||
/// province, not the spelling, so `NF` and `LB` are both Newfoundland and
|
||||
/// Labrador and count once between them. N1MM counts them as two.
|
||||
private static readonly IReadOnlyDictionary<string, string> OtherSpellings =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["NF"] = "NL",
|
||||
["LB"] = "NL",
|
||||
["NFL"] = "NL",
|
||||
["QB"] = "QC",
|
||||
["PQ"] = "QC",
|
||||
["PEI"] = "PE",
|
||||
["NWT"] = "NT",
|
||||
};
|
||||
|
||||
/// The province or state a received ARRL DX exchange names, or the exchange
|
||||
/// as it was sent when it names none.
|
||||
public static string ArrlDxArea(string received)
|
||||
{
|
||||
string text = received.Trim();
|
||||
return OtherSpellings.TryGetValue(text, out string? province) ? province : text.ToUpperInvariant();
|
||||
}
|
||||
|
||||
/// What CQ WW RTTY counts: the 48 contiguous states, DC, and the Canadian
|
||||
/// areas as that contest names them, which is not the postal list — it
|
||||
/// splits Newfoundland from Labrador and spells out `NWT` and `PEI`.
|
||||
public static readonly IReadOnlySet<string> CqWwRttyAreas =
|
||||
new HashSet<string>(
|
||||
ContiguousStates
|
||||
.Concat(["DC", "NB", "NS", "QC", "ON", "MB", "SK", "AB", "BC", "NU", "YT"])
|
||||
.Concat(SplitCanadianAreas),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsStateOrProvince(string text) =>
|
||||
AllStates.Contains(text.Trim()) || Provinces.Contains(text.Trim()) ||
|
||||
text.Trim().Equals("DC", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Nonemm.Contests;
|
||||
|
||||
/// A QSO together with what the country file says about it, so a contest can
|
||||
/// score by country, zone or continent without looking anything up itself.
|
||||
public sealed record QsoContext(Qso Qso, CountryLookup? Country, StationInfo Me)
|
||||
public sealed record QsoContext(Qso Qso, CountryLookup? Country, StationInfo Me, ContestEntry Entry)
|
||||
{
|
||||
public Band? Band => Qso.Band;
|
||||
|
||||
@@ -15,10 +15,12 @@ public sealed record QsoContext(Qso Qso, CountryLookup? Country, StationInfo Me)
|
||||
/// the operator corrected the country by hand, and then the correction is
|
||||
/// the point.
|
||||
public string Continent =>
|
||||
Qso.Continent.Length > 0 ? Qso.Continent : Country?.Continent ?? "";
|
||||
Qso.Continent.Trim().Length > 0 ? Qso.Continent.Trim() : Country?.Continent ?? "";
|
||||
|
||||
public string CountryPrefix =>
|
||||
Qso.CountryPrefix.Length > 0 ? Qso.CountryPrefix : Country?.Entity.PrimaryPrefix ?? "";
|
||||
Qso.CountryPrefix.Trim().Length > 0
|
||||
? Qso.CountryPrefix.Trim()
|
||||
: Country?.Entity.PrimaryPrefix ?? "";
|
||||
|
||||
public int CqZone => Country?.CqZone ?? Qso.Zone;
|
||||
|
||||
@@ -26,5 +28,13 @@ public sealed record QsoContext(Qso Qso, CountryLookup? Country, StationInfo Me)
|
||||
|
||||
public bool IsSameCountry => CountryPrefix.Length > 0 && CountryPrefix == Me.CountryPrefix;
|
||||
|
||||
/// Great-circle kilometres between the two grid squares, and zero when
|
||||
/// either station has no locator.
|
||||
public double DistanceKm =>
|
||||
GridSquare.TryParse(Qso.GridSquare, out GridSquare theirs)
|
||||
&& GridSquare.TryParse(Me.GridSquare, out GridSquare mine)
|
||||
? mine.DistanceTo(theirs)
|
||||
: 0.0;
|
||||
|
||||
public bool IsSameContinent => Continent.Length > 0 && Continent == Me.Continent;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace Nonemm.Contests.Rules;
|
||||
/// ARRL International DX, CW and SSB. W and VE stations work DX and nobody
|
||||
/// else, so the exchange and the multiplier both depend on which side the
|
||||
/// operator is on.
|
||||
///
|
||||
/// The received power or state goes in the section column, which is where N1MM
|
||||
/// keeps it, so a log written by either program scores the same in the other.
|
||||
public sealed class ArrlDx : Contest
|
||||
{
|
||||
private readonly ModeCategory mode;
|
||||
@@ -22,8 +25,8 @@ public sealed class ArrlDx : Contest
|
||||
[
|
||||
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
|
||||
IsNorthAmericanHome(me)
|
||||
? new ExchangeField("Pwr", ExchangeSlot.Exchange1, ExchangeFieldKind.Power)
|
||||
: new ExchangeField("S/P", ExchangeSlot.Exchange1, ExchangeFieldKind.UsStateOrCanadianProvince),
|
||||
? new ExchangeField("Pwr", ExchangeSlot.Section, ExchangeFieldKind.Power)
|
||||
: new ExchangeField("S/P", ExchangeSlot.Section, ExchangeFieldKind.UsStateOrCanadianProvince),
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> MultiplierNames => ["Mults"];
|
||||
@@ -53,13 +56,13 @@ public sealed class ArrlDx : Contest
|
||||
{
|
||||
return qso.CountryPrefix.Length == 0 ? [] : [new Multiplier(1, qso.CountryPrefix, band)];
|
||||
}
|
||||
string received = qso.Qso.Exchange1.Trim().ToUpperInvariant();
|
||||
string received = StatesAndProvinces.ArrlDxArea(qso.Qso.Section);
|
||||
return StatesAndProvinces.ArrlDxMultipliers.Contains(received)
|
||||
? [new Multiplier(1, received, band)]
|
||||
: [];
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
@@ -71,7 +74,7 @@ public sealed class ArrlDx : Contest
|
||||
[
|
||||
new CabrilloField(qso.Call.Text, 13),
|
||||
new CabrilloField(qso.ReceivedReport, 3),
|
||||
new CabrilloField(qso.Exchange1, 6),
|
||||
new CabrilloField(qso.Section, 6),
|
||||
]);
|
||||
|
||||
private static bool IsNorthAmericanHome(StationInfo me) => IsNorthAmerican(me.CountryPrefix);
|
||||
|
||||
@@ -73,17 +73,17 @@ public sealed class CqWorldWide : Contest
|
||||
public string SentExchangeFor(StationInfo me) =>
|
||||
IsRtty ? $"{me.CqZone} {HomeOf(me)}" : me.CqZone.ToString();
|
||||
|
||||
/// A maritime mobile station counts for no country, and still scores by
|
||||
/// the continent it is operating from, which is what N1MM logs.
|
||||
public int PointsFor(QsoContext qso)
|
||||
{
|
||||
if (!qso.Qso.Call.CountsForEntity)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (IsRtty)
|
||||
{
|
||||
return qso.IsSameCountry ? 1 : qso.IsSameContinent ? 2 : 3;
|
||||
return qso.IsSameCountry && qso.Qso.Call.CountsForEntity ? 1
|
||||
: qso.IsSameContinent ? 2
|
||||
: 3;
|
||||
}
|
||||
if (qso.IsSameCountry)
|
||||
if (qso.IsSameCountry && qso.Qso.Call.CountsForEntity)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
@@ -107,14 +107,14 @@ public sealed class CqWorldWide : Contest
|
||||
found.Add(new Multiplier(2, qso.CountryPrefix, band));
|
||||
}
|
||||
string home = qso.Qso.Section.Trim().ToUpperInvariant();
|
||||
if (IsRtty && StatesAndProvinces.IsStateOrProvince(home))
|
||||
if (IsRtty && StatesAndProvinces.CqWwRttyAreas.Contains(home))
|
||||
{
|
||||
found.Add(new Multiplier(3, home, band));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => IsRtty
|
||||
? new CabrilloExchange(
|
||||
|
||||
@@ -74,7 +74,7 @@ public sealed class CqWpx : Contest
|
||||
return prefix is null ? [] : [new Multiplier(1, prefix, "")];
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class GeneralLogging : Contest
|
||||
|
||||
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso) => [];
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Qsos;
|
||||
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Qsos;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
|
||||
@@ -4,6 +4,9 @@ namespace Nonemm.Contests.Rules;
|
||||
|
||||
/// IARU HF World Championship. The exchange is an ITU zone or, for a society
|
||||
/// headquarters station, its abbreviation; both count as multipliers per band.
|
||||
///
|
||||
/// The received zone or abbreviation goes in the section column, which is where
|
||||
/// N1MM keeps it.
|
||||
public sealed class IaruHf : Contest
|
||||
{
|
||||
public string Name => "IARU";
|
||||
@@ -15,7 +18,7 @@ public sealed class IaruHf : Contest
|
||||
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
|
||||
[
|
||||
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
|
||||
new ExchangeField("Zone/HQ", ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 },
|
||||
new ExchangeField("Zone/HQ", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 6 },
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> MultiplierNames => ["Zones", "HQ"];
|
||||
@@ -55,7 +58,7 @@ public sealed class IaruHf : Contest
|
||||
return [new Multiplier(IsZone(exchange) ? 1 : 2, exchange, band)];
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
@@ -67,10 +70,10 @@ public sealed class IaruHf : Contest
|
||||
[
|
||||
new CabrilloField(qso.Call.Text, 13),
|
||||
new CabrilloField(qso.ReceivedReport, 3),
|
||||
new CabrilloField(qso.Exchange1, 6),
|
||||
new CabrilloField(qso.Section, 6),
|
||||
]);
|
||||
|
||||
private static string Exchange(QsoContext qso) => qso.Qso.Exchange1.Trim().ToUpperInvariant();
|
||||
private static string Exchange(QsoContext qso) => qso.Qso.Section.Trim().ToUpperInvariant();
|
||||
|
||||
private static bool IsZone(string exchange) =>
|
||||
exchange.Length > 0 && exchange.All(char.IsAsciiDigit);
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class NorthAmericanQsoParty : Contest
|
||||
: [];
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed class RttyRoundup : Contest
|
||||
return qso.CountryPrefix.Length == 0 ? [] : [new Multiplier(1, qso.CountryPrefix, "")];
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed class Sweepstakes : Contest
|
||||
return ArrlSections.IsSection(section) ? [new Multiplier(1, section, "")] : [];
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
/// Sweepstakes puts the callsign after the serial number and precedence
|
||||
/// rather than first, which is why the writer takes the whole column list.
|
||||
|
||||
@@ -94,7 +94,7 @@ public sealed class Wae : Contest
|
||||
: [new Multiplier(1, value, qso.Band?.Name ?? "", weight)];
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
/// A QTC is written as its own Cabrillo record: the station that received
|
||||
/// the traffic, the series, the station that sent it, and then the contact
|
||||
|
||||
114
src/Nonemm.Contests/Udc/UdcCondition.cs
Normal file
114
src/Nonemm.Contests/Udc/UdcCondition.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Udc;
|
||||
|
||||
/// One condition from a `.udc` scoring list, tested against a contact.
|
||||
///
|
||||
/// N1MM's published keywords, plus three prefixed forms: `SectIs_x`, `ExchIs_x`
|
||||
/// and `MiscIs_x` compare the seventh character onwards with the received
|
||||
/// section, exchange or misc box. A condition that is none of the keywords is
|
||||
/// taken as a country prefix or a whole callsign.
|
||||
public static class UdcCondition
|
||||
{
|
||||
public static bool Matches(QsoContext qso, string condition, string mySentExchange)
|
||||
{
|
||||
string test = condition.Trim().ToUpperInvariant();
|
||||
return test switch
|
||||
{
|
||||
"" => false,
|
||||
"MYCOUNTRY" => qso.IsSameCountry,
|
||||
"MYEXCHANGE" => Same(qso.Qso.Section, mySentExchange),
|
||||
"MYGRID" => SameGridField(qso.Qso.GridSquare, qso.Me.GridSquare),
|
||||
"MYCQZONE" => qso.CqZone > 0 && qso.CqZone == qso.Me.CqZone,
|
||||
"MYIARUZONE" => qso.ItuZone > 0 && qso.ItuZone == qso.Me.ItuZone,
|
||||
"SAMECONTINENT" => qso.IsSameContinent,
|
||||
"OTHERCONTINENT" => qso.Continent.Length > 0 && !qso.IsSameContinent,
|
||||
"ISEXCH" => qso.Qso.Exchange1.Trim().Length > 0,
|
||||
"ISMISC" => qso.Qso.MiscText.Trim().Length > 0,
|
||||
"ISCOMMENT" => qso.Qso.Comment.Trim().Length > 0,
|
||||
"ISMULT1" => qso.Qso.IsMultiplier1,
|
||||
"ISMULT2" => qso.Qso.IsMultiplier2,
|
||||
"ISMULT3" => qso.Qso.IsMultiplier3,
|
||||
"EXCHISNUM" => IsNumber(qso.Qso.Exchange1),
|
||||
"EXCHISNOTNUM" => qso.Qso.Exchange1.Trim().Length > 0 && !IsNumber(qso.Qso.Exchange1),
|
||||
"EXCHIS_EMPTY" => qso.Qso.Exchange1.Trim().Length == 0,
|
||||
"MISCISNUM" => IsNumber(qso.Qso.MiscText),
|
||||
_ => MatchesLonger(qso, test),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesLonger(QsoContext qso, string test)
|
||||
{
|
||||
if (test.Length > 7)
|
||||
{
|
||||
string value = test[7..];
|
||||
switch (test[..6])
|
||||
{
|
||||
case "SECTIS":
|
||||
return Same(qso.Qso.Section, value);
|
||||
case "EXCHIS":
|
||||
return Same(qso.Qso.Exchange1, value);
|
||||
case "MISCIS":
|
||||
return Same(qso.Qso.MiscText, value);
|
||||
}
|
||||
}
|
||||
int plus = test.IndexOf('+');
|
||||
if (plus > 0)
|
||||
{
|
||||
return Matches(qso, test[..plus], "") && IsBand(qso, test[(plus + 1)..]);
|
||||
}
|
||||
return IsBand(qso, test)
|
||||
|| IsMode(qso, test)
|
||||
|| IsContinent(qso, test)
|
||||
|| IsCallSuffix(qso, test)
|
||||
|| Same(qso.CountryPrefix, test)
|
||||
|| Same(qso.Qso.Call.Text, test);
|
||||
}
|
||||
|
||||
/// The band names a `.udc` file uses are the band names: `160M`, `70CM`.
|
||||
/// A points condition may leave the `M` off — `SAMECONTINENT+160`.
|
||||
private static bool IsBand(QsoContext qso, string test) =>
|
||||
qso.Band is not null &&
|
||||
(Same(qso.Band.Name, test) || Same(qso.Band.Name, test + "M"));
|
||||
|
||||
private static bool IsMode(QsoContext qso, string test) => test switch
|
||||
{
|
||||
"CW" => qso.ModeCategory == ModeCategory.Cw,
|
||||
"SSB" or "USB" or "LSB" => qso.ModeCategory == ModeCategory.Phone,
|
||||
"DIGI" => qso.ModeCategory == ModeCategory.Digital,
|
||||
"RTTY" or "PSK" or "FT8" or "FT4" or "FM" or "AM" => Same(qso.Qso.Mode.Name, test),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static bool IsContinent(QsoContext qso, string test) => test switch
|
||||
{
|
||||
"EU" or "NA" or "SA" or "AS" or "AF" or "OC" or "AN" => Same(qso.Continent, test),
|
||||
"MYCONTINENT" => qso.IsSameContinent,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// `/P`, `/M` and `/QRP` match what the station signs after the last slash.
|
||||
private static bool IsCallSuffix(QsoContext qso, string test)
|
||||
{
|
||||
if (!test.StartsWith('/'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
string call = qso.Qso.Call.Text;
|
||||
int slash = call.LastIndexOf('/');
|
||||
return slash >= 0 && Same(call[slash..], test);
|
||||
}
|
||||
|
||||
private static bool SameGridField(string theirs, string mine) =>
|
||||
theirs.Trim().Length >= 4 && mine.Trim().Length >= 4 &&
|
||||
Same(theirs.Trim()[..4], mine.Trim()[..4]);
|
||||
|
||||
private static bool IsNumber(string text)
|
||||
{
|
||||
string trimmed = text.Trim();
|
||||
return trimmed.Length > 0 && trimmed.All(char.IsAsciiDigit);
|
||||
}
|
||||
|
||||
private static bool Same(string left, string right) =>
|
||||
left.Trim().Equals(right.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -3,50 +3,102 @@ using Nonemm.Core;
|
||||
namespace Nonemm.Contests.Udc;
|
||||
|
||||
/// One of a user-defined contest's up-to-three multipliers: where its value
|
||||
/// comes from and how often it may be counted.
|
||||
/// comes from, which stations may bring it in, and how often it may be counted.
|
||||
///
|
||||
/// Left out: `CallHist`, which counts a value from the call history file, and
|
||||
/// needs that file loaded before a multiplier can be worked out.
|
||||
public sealed class UdcMultiplier
|
||||
{
|
||||
private readonly int index;
|
||||
private readonly string source;
|
||||
private readonly UdcMultiplierScope scope;
|
||||
private readonly IReadOnlyList<string> onlyForCountries;
|
||||
private readonly int weight;
|
||||
private readonly bool skipsMyOwn;
|
||||
private readonly IReadOnlyList<string> onlyFor;
|
||||
private readonly IReadOnlyList<string> notFor;
|
||||
private readonly IReadOnlyList<string> onlyForSections;
|
||||
|
||||
public UdcMultiplier(
|
||||
int index,
|
||||
string source,
|
||||
UdcMultiplierScope scope,
|
||||
IReadOnlyList<string> onlyForCountries)
|
||||
public UdcMultiplier(int index, UdcFile file, UdcMultiplierScope shared)
|
||||
{
|
||||
this.index = index;
|
||||
this.source = source;
|
||||
this.scope = scope;
|
||||
this.onlyForCountries = onlyForCountries;
|
||||
string suffix = index == 1 ? "" : index.ToString();
|
||||
source = file.Text($"MultSqlString{suffix}").Trim().ToUpperInvariant();
|
||||
UdcMultiplierScope own = (UdcMultiplierScope)file.Number($"IsMult{index}Per", 0);
|
||||
scope = own != UdcMultiplierScope.None ? own
|
||||
: shared != UdcMultiplierScope.None ? shared
|
||||
: UdcMultiplierScope.PerBand;
|
||||
weight = file.Number($"MultMult{suffix}", 1);
|
||||
skipsMyOwn = file.Flag($"DoNotCountMeAsMult{suffix}", false);
|
||||
onlyFor = file.List($"CountMultOnlyFor{suffix}");
|
||||
notFor = file.List($"DoNotCountMultOnlyFor{suffix}");
|
||||
onlyForSections = file.List($"CountMultOnlyForSec{suffix}");
|
||||
}
|
||||
|
||||
public bool IsDefined => source.Length > 0;
|
||||
|
||||
/// What one of these is worth in the score. `MultMult` is a weight, not a
|
||||
/// switch: a contest can count a multiplier twice, and one weighted zero
|
||||
/// still shows as worked and adds nothing.
|
||||
public int Weight => weight;
|
||||
|
||||
public Multiplier? For(QsoContext qso)
|
||||
{
|
||||
if (scope == UdcMultiplierScope.None)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (onlyForCountries.Count > 0 &&
|
||||
!onlyForCountries.Contains(qso.CountryPrefix, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string? value = ValueFor(qso);
|
||||
return string.IsNullOrEmpty(value) ? null : new Multiplier(index, value, ScopeKey(qso));
|
||||
if (scope == UdcMultiplierScope.None || string.IsNullOrEmpty(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return Counts(qso, value) ? new Multiplier(index, value, ScopeKey(qso), weight) : null;
|
||||
}
|
||||
|
||||
private string? ValueFor(QsoContext qso) => source.ToUpperInvariant() switch
|
||||
/// `CountMultOnlyFor` and `DoNotCountMultOnlyFor` hold country prefixes or
|
||||
/// continent abbreviations, and for a prefix multiplier the prefixes
|
||||
/// themselves, so a station is matched against all three.
|
||||
private bool Counts(QsoContext qso, string value)
|
||||
{
|
||||
if (skipsMyOwn && IsMyOwn(qso, value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (onlyFor.Count > 0 && !onlyFor.Any(p => Names(qso, value, p)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (notFor.Any(p => Names(qso, value, p)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return onlyForSections.Count == 0
|
||||
|| onlyForSections.Contains(qso.Qso.Section.Trim(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private bool IsMyOwn(QsoContext qso, string value) =>
|
||||
IsPrefixSource
|
||||
? qso.Me.Callsign.StartsWith(value, StringComparison.OrdinalIgnoreCase)
|
||||
: qso.IsSameCountry;
|
||||
|
||||
private bool Names(QsoContext qso, string value, string wanted) =>
|
||||
wanted.Equals(qso.CountryPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
|| wanted.Equals(qso.Continent, StringComparison.OrdinalIgnoreCase)
|
||||
|| (IsPrefixSource && wanted.Equals(value, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private bool IsPrefixSource => source is "WPXPREFIX" or "2LPREFIX";
|
||||
|
||||
private string? ValueFor(QsoContext qso) => source switch
|
||||
{
|
||||
"COUNTRYPREFIX" => qso.CountryPrefix,
|
||||
"EU_COUNTRY" or "AS_COUNTRY" or "NA_COUNTRY" or "SA_COUNTRY" or "AF_COUNTRY" or "OC_COUNTRY" =>
|
||||
CountryOnContinent(qso, source[..2]),
|
||||
"WPXPREFIX" => qso.Qso.Call.WpxPrefix(),
|
||||
"2LPREFIX" => Truncate(qso.Qso.Call.Text, 2),
|
||||
"LASTLETTER" => LastLetter(qso.Qso.Call.Text),
|
||||
"SECTION" or "SECT" => Upper(qso.Qso.Section),
|
||||
"EXCHANGE" or "EXCH" => Upper(qso.Qso.Exchange1),
|
||||
"MISC" or "MISCTEXT" => Upper(qso.Qso.MiscText),
|
||||
"COMMENT" => Upper(qso.Qso.Comment),
|
||||
"CALLSIGN" => qso.Qso.Call.Text,
|
||||
"CQZONE" or "ZN" => qso.Qso.Zone > 0 ? qso.Qso.Zone.ToString() : null,
|
||||
"CQZONE" or "ZN" => ZoneName(qso.Qso.Zone, qso.CqZone),
|
||||
"IARUZONE" => ZoneName(qso.Qso.Zone, qso.ItuZone),
|
||||
"GRID" => Truncate(qso.Qso.GridSquare, 4),
|
||||
"SGRID" => Truncate(qso.Qso.GridSquare, 6),
|
||||
"FIELD" => Truncate(qso.Qso.GridSquare, 2),
|
||||
@@ -55,6 +107,16 @@ public sealed class UdcMultiplier
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? CountryOnContinent(QsoContext qso, string continent) =>
|
||||
continent.Equals(qso.Continent, StringComparison.OrdinalIgnoreCase) ? qso.CountryPrefix : null;
|
||||
|
||||
/// The zone the operator typed, and the country file's zone when the box is
|
||||
/// empty.
|
||||
private static string? ZoneName(int received, int fromCountryFile) =>
|
||||
received > 0 ? received.ToString()
|
||||
: fromCountryFile > 0 ? fromCountryFile.ToString()
|
||||
: null;
|
||||
|
||||
private string ScopeKey(QsoContext qso) => scope switch
|
||||
{
|
||||
UdcMultiplierScope.PerBand => qso.Band?.Name ?? "",
|
||||
@@ -65,6 +127,12 @@ public sealed class UdcMultiplier
|
||||
|
||||
private static string Upper(string text) => text.Trim().ToUpperInvariant();
|
||||
|
||||
private static string? LastLetter(string call)
|
||||
{
|
||||
string trimmed = call.Trim().ToUpperInvariant();
|
||||
return trimmed.Length == 0 ? null : trimmed[^1..];
|
||||
}
|
||||
|
||||
private static string? Truncate(string text, int length)
|
||||
{
|
||||
string trimmed = text.Trim().ToUpperInvariant();
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
using Nonemm.Core;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Nonemm.Contests.Udc;
|
||||
|
||||
/// Reads N1MM's `PointsPerContact` setting. A plain number scores every contact
|
||||
/// the same; a comma list gives a condition and a score in turn, and the first
|
||||
/// condition in the list that matches wins. N1MM's help describes an order of
|
||||
/// precedence, but its code walks the list left to right, and a file written
|
||||
/// for N1MM has to score the same here.
|
||||
/// Reads N1MM's `PointsPerContact` setting, and the same format in
|
||||
/// `BonusPoints`. A plain number scores every contact the same; a comma list
|
||||
/// gives a condition and a score in turn, and the first condition in the list
|
||||
/// that matches wins. N1MM's help describes an order of precedence, but its
|
||||
/// code walks the list left to right, and a file written for N1MM has to score
|
||||
/// the same here.
|
||||
///
|
||||
/// Two more forms. `Exchange, *` and `Misc, *` score the contact the number the
|
||||
/// operator typed in that box. A list of `low/high/points` ranges separated by
|
||||
/// semicolons scores by the distance to the other station's grid square: a
|
||||
/// contact inside a range scores the range's points on top of whatever the
|
||||
/// comma list gave, and one outside every range scores the distance itself in
|
||||
/// kilometres, which is what N1MM does.
|
||||
public sealed class UdcPoints
|
||||
{
|
||||
private readonly int flat;
|
||||
private readonly List<(string Condition, int Points)> rules = [];
|
||||
private readonly List<(int Low, int High, int Points)> ranges = [];
|
||||
private readonly bool exchangeIsPoints;
|
||||
private readonly bool miscIsPoints;
|
||||
|
||||
public UdcPoints(string setting)
|
||||
{
|
||||
@@ -22,22 +33,51 @@ public sealed class UdcPoints
|
||||
}
|
||||
for (int at = 0; at + 1 < parts.Length; at += 2)
|
||||
{
|
||||
if (int.TryParse(parts[at + 1].Trim(), out int points))
|
||||
string condition = parts[at].Trim();
|
||||
string score = parts[at + 1].Trim();
|
||||
if (score == "*")
|
||||
{
|
||||
rules.Add((parts[at].Trim(), points));
|
||||
exchangeIsPoints |= condition.Equals("Exchange", StringComparison.OrdinalIgnoreCase);
|
||||
miscIsPoints |= condition.Equals("Misc", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
else if (int.TryParse(score, NumberStyles.Integer, CultureInfo.InvariantCulture, out int points))
|
||||
{
|
||||
rules.Add((condition, points));
|
||||
}
|
||||
}
|
||||
ReadRanges(setting);
|
||||
}
|
||||
|
||||
public int For(QsoContext qso)
|
||||
public int For(QsoContext qso, string mySentExchange)
|
||||
{
|
||||
if (rules.Count == 0)
|
||||
int points = Typed(qso) ?? Ruled(qso, mySentExchange);
|
||||
if (ranges.Count == 0)
|
||||
{
|
||||
return flat;
|
||||
return points;
|
||||
}
|
||||
int km = (int)Math.Round(qso.DistanceKm + 0.5);
|
||||
int? inRange = RangePoints(km);
|
||||
return inRange is null ? km : points + inRange.Value;
|
||||
}
|
||||
|
||||
private int? Typed(QsoContext qso)
|
||||
{
|
||||
if (exchangeIsPoints && int.TryParse(qso.Qso.Exchange1.Trim(), out int fromExchange))
|
||||
{
|
||||
return fromExchange;
|
||||
}
|
||||
if (miscIsPoints && int.TryParse(qso.Qso.MiscText.Trim(), out int fromMisc))
|
||||
{
|
||||
return fromMisc;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private int Ruled(QsoContext qso, string mySentExchange)
|
||||
{
|
||||
foreach ((string condition, int points) in rules)
|
||||
{
|
||||
if (Tests.Any(test => test(qso, condition)))
|
||||
if (UdcCondition.Matches(qso, condition, mySentExchange))
|
||||
{
|
||||
return points;
|
||||
}
|
||||
@@ -45,44 +85,30 @@ public sealed class UdcPoints
|
||||
return flat;
|
||||
}
|
||||
|
||||
private static readonly Func<QsoContext, string, bool>[] Tests =
|
||||
[
|
||||
MatchesBand,
|
||||
MatchesMode,
|
||||
MatchesContinent,
|
||||
MatchesCountry,
|
||||
];
|
||||
|
||||
private static bool MatchesBand(QsoContext qso, string condition) =>
|
||||
qso.Band is not null &&
|
||||
condition.EndsWith('m') &&
|
||||
MeterName(qso.Band).Equals(condition, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool MatchesMode(QsoContext qso, string condition) => condition.ToUpperInvariant() switch
|
||||
private int? RangePoints(int km)
|
||||
{
|
||||
"CW" => qso.ModeCategory == ModeCategory.Cw,
|
||||
"SSB" => qso.ModeCategory == ModeCategory.Phone,
|
||||
"DIGI" => qso.ModeCategory == ModeCategory.Digital,
|
||||
"RTTY" or "PSK" or "FT8" or "FT4" or "FM" or "AM" =>
|
||||
qso.Qso.Mode.Name.Equals(condition, StringComparison.OrdinalIgnoreCase),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static bool MatchesContinent(QsoContext qso, string condition) => condition.ToUpperInvariant() switch
|
||||
foreach ((int low, int high, int points) in ranges)
|
||||
{
|
||||
"SAMECONTINENT" => qso.IsSameContinent,
|
||||
"OTHERCONTINENT" => !qso.IsSameContinent,
|
||||
"EU" or "NA" or "SA" or "AS" or "AF" or "OC" or "AN" =>
|
||||
qso.Continent.Equals(condition, StringComparison.OrdinalIgnoreCase),
|
||||
_ => false,
|
||||
};
|
||||
if (km >= low && km <= high)
|
||||
{
|
||||
return points;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool MatchesCountry(QsoContext qso, string condition) =>
|
||||
condition.Equals("MyCountry", StringComparison.OrdinalIgnoreCase)
|
||||
? qso.IsSameCountry
|
||||
: qso.CountryPrefix.Equals(condition, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// The band names UDC files use: `160m`, `80m`, `70cm` and so on, which are
|
||||
/// the band names lower-cased.
|
||||
private static string MeterName(Band band) => band.Name.ToLowerInvariant();
|
||||
private void ReadRanges(string setting)
|
||||
{
|
||||
foreach (string entry in setting.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
string[] bounds = entry.Split('/');
|
||||
if (bounds.Length == 3 &&
|
||||
int.TryParse(bounds[0].Trim(), out int low) &&
|
||||
int.TryParse(bounds[1].Trim(), out int high) &&
|
||||
int.TryParse(bounds[2].Trim(), out int points))
|
||||
{
|
||||
ranges.Add((low, high, points));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
150
src/Nonemm.Contests/Udc/UdcPointsMultiplier.cs
Normal file
150
src/Nonemm.Contests/Udc/UdcPointsMultiplier.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using System.Globalization;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Udc;
|
||||
|
||||
/// The `PointsMultBy…` settings, which scale what a contact scores. Each
|
||||
/// setting is a list of a key and a factor in turn, and the first key that
|
||||
/// matches the contact gives the factor. The settings multiply together, so a
|
||||
/// contest can pay double on 160 metres and triple outside the continent at the
|
||||
/// same time.
|
||||
///
|
||||
/// `PointsMultAtTimeGMT` and `PointsMultAtTimeLocal` are a start time, an end
|
||||
/// time and a factor, both times `HHMM`. A window whose end is before its start
|
||||
/// runs over midnight.
|
||||
///
|
||||
/// `PowerMult` is the operator's own entry: a list of `QRPP`, `QRP`, `LP` or
|
||||
/// `HP` and a factor, which multiplies the whole score.
|
||||
///
|
||||
/// `PointsMultByCategory` is not about the operator's own entry: it scales a
|
||||
/// contact with a station signing QRP, and its keys are the last three or four
|
||||
/// characters of that station's callsign — `QRP` and `/QRP`. `PowerMult`,
|
||||
/// which scales the whole score by the operator's own power category, is in
|
||||
/// `UserDefinedContest` where the score is added up.
|
||||
public sealed class UdcPointsMultiplier
|
||||
{
|
||||
private readonly List<(string Key, double Factor)> byBand;
|
||||
private readonly List<(string Key, double Factor)> byMode;
|
||||
private readonly List<(string Key, double Factor)> byContinent;
|
||||
private readonly List<(string Key, double Factor)> byCountry;
|
||||
private readonly List<(int From, int To, double Factor)> atTimeUtc;
|
||||
private readonly List<(int From, int To, double Factor)> atTimeLocal;
|
||||
private readonly List<(string Key, double Factor)> byCategory;
|
||||
private readonly List<(string Key, double Factor)> byPower;
|
||||
|
||||
public UdcPointsMultiplier(UdcFile file)
|
||||
{
|
||||
byBand = ReadPairs(file.Text("PointsMultByBand"));
|
||||
byMode = ReadPairs(file.Text("PointsMultByMode"));
|
||||
byContinent = ReadPairs(file.Text("PointsMultByContinent"));
|
||||
byCountry = ReadPairs(file.Text("PointsMultByCountry"));
|
||||
atTimeUtc = ReadWindows(file.Text("PointsMultAtTimeGMT"));
|
||||
atTimeLocal = ReadWindows(file.Text("PointsMultAtTimeLocal"));
|
||||
byCategory = ReadPairs(file.Text("PointsMultByCategory"));
|
||||
byPower = ReadPairs(file.Text("PowerMult"));
|
||||
}
|
||||
|
||||
public double For(QsoContext qso) =>
|
||||
First(byBand, qso) * First(byMode, qso) * First(byContinent, qso) * First(byCountry, qso) *
|
||||
InWindow(atTimeUtc, qso.Qso.TimestampUtc) *
|
||||
InWindow(atTimeLocal, qso.Qso.TimestampUtc.ToLocalTime()) *
|
||||
ForCategory(qso);
|
||||
|
||||
/// What the whole score is multiplied by, from the power category of the
|
||||
/// entry. The setting names the categories `LP` and `HP`, and the log
|
||||
/// stores them as `LOW` and `HIGH`.
|
||||
public double ForEntry(ContestEntry entry)
|
||||
{
|
||||
foreach ((string key, double factor) in byPower)
|
||||
{
|
||||
string category = key.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"LP" => "LOW",
|
||||
"HP" => "HIGH",
|
||||
string other => other,
|
||||
};
|
||||
if (category.Equals(entry.PowerCategory.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return factor;
|
||||
}
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
/// Only a station signing QRP is scaled, which is N1MM's rule.
|
||||
private double ForCategory(QsoContext qso)
|
||||
{
|
||||
string call = qso.Qso.Call.Text.ToUpperInvariant();
|
||||
if (!call.Contains("QRP", StringComparison.Ordinal))
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
foreach ((string key, double factor) in byCategory)
|
||||
{
|
||||
if (call.EndsWith(key.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return factor;
|
||||
}
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
private static double First(List<(string Key, double Factor)> pairs, QsoContext qso)
|
||||
{
|
||||
foreach ((string key, double factor) in pairs)
|
||||
{
|
||||
if (UdcCondition.Matches(qso, key, ""))
|
||||
{
|
||||
return factor;
|
||||
}
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
private static double InWindow(List<(int From, int To, double Factor)> windows, DateTime at)
|
||||
{
|
||||
int time = (at.Hour * 100) + at.Minute;
|
||||
foreach ((int from, int to, double factor) in windows)
|
||||
{
|
||||
bool inside = from <= to ? time >= from && time <= to : time >= from || time <= to;
|
||||
if (inside)
|
||||
{
|
||||
return factor;
|
||||
}
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
private static List<(string, double)> ReadPairs(string setting)
|
||||
{
|
||||
string[] parts = setting.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
List<(string, double)> pairs = [];
|
||||
for (int at = 0; at + 1 < parts.Length; at += 2)
|
||||
{
|
||||
if (TryFactor(parts[at + 1], out double factor))
|
||||
{
|
||||
pairs.Add((parts[at].Trim(), factor));
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
private static List<(int, int, double)> ReadWindows(string setting)
|
||||
{
|
||||
string[] parts = setting.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
List<(int, int, double)> windows = [];
|
||||
for (int at = 0; at + 2 < parts.Length; at += 3)
|
||||
{
|
||||
if (int.TryParse(parts[at].Trim(), out int from) &&
|
||||
int.TryParse(parts[at + 1].Trim(), out int to) &&
|
||||
TryFactor(parts[at + 2], out double factor))
|
||||
{
|
||||
windows.Add((from, to, factor));
|
||||
}
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
private static bool TryFactor(string text, out double factor) =>
|
||||
double.TryParse(text.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out factor);
|
||||
}
|
||||
@@ -9,15 +9,21 @@ public sealed class UserDefinedContest : Contest
|
||||
{
|
||||
private readonly UdcFile file;
|
||||
private readonly UdcPoints points;
|
||||
private readonly UdcPoints bonusPoints;
|
||||
private readonly UdcPointsMultiplier pointsMultiplier;
|
||||
private readonly IReadOnlyList<UdcMultiplier> multipliers;
|
||||
private readonly IReadOnlyList<ExchangeField> exchangeFields;
|
||||
private readonly IReadOnlyList<string> workableStations;
|
||||
|
||||
public UserDefinedContest(UdcFile file)
|
||||
{
|
||||
this.file = file;
|
||||
points = new UdcPoints(file.Text("PointsPerContact", "1"));
|
||||
bonusPoints = new UdcPoints(file.Text("BonusPoints"));
|
||||
pointsMultiplier = new UdcPointsMultiplier(file);
|
||||
multipliers = ReadMultipliers(file);
|
||||
exchangeFields = ReadExchangeFields(file);
|
||||
workableStations = file.List("IsWorkable");
|
||||
}
|
||||
|
||||
public static UserDefinedContest Load(string path) =>
|
||||
@@ -33,6 +39,7 @@ public sealed class UserDefinedContest : Contest
|
||||
|
||||
public IReadOnlyList<string> MultiplierNames =>
|
||||
new[] { "Multiplier1Name", "Multiplier2Name", "Multiplier3Name" }
|
||||
.Take(multipliers.Count)
|
||||
.Select(key => file.Text(key))
|
||||
.Where(name => name.Length > 0)
|
||||
.ToList();
|
||||
@@ -45,9 +52,27 @@ public sealed class UserDefinedContest : Contest
|
||||
_ => DupeScope.PerBand,
|
||||
};
|
||||
|
||||
/// `DupeSqlString` names one more field that makes a contact distinct: a
|
||||
/// station worked again with a different section, exchange, mode or grid
|
||||
/// square is not a dupe.
|
||||
public string ExtraDupeKey(Qso qso) => file.Number("DupeSqlString", 0) switch
|
||||
{
|
||||
1 => qso.Section.Trim().ToUpperInvariant(),
|
||||
2 => qso.Exchange1.Trim().ToUpperInvariant(),
|
||||
3 => qso.Mode.Name,
|
||||
4 => qso.GridSquare.Trim().ToUpperInvariant(),
|
||||
_ => "",
|
||||
};
|
||||
|
||||
public bool HasSerialNumbers =>
|
||||
file.Text("DefaultContestExchange").StartsWith("001", StringComparison.Ordinal);
|
||||
|
||||
public bool ShowsWarcBands => file.Flag("ShowWarcBands", false);
|
||||
|
||||
public bool UsesItuZones =>
|
||||
file.Text("ZoneType", "CQ").StartsWith("IARU", StringComparison.OrdinalIgnoreCase)
|
||||
|| file.Text("ZoneType", "CQ").StartsWith("ITU", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public IReadOnlyList<ModeCategory> Modes => file.Text("Mode", "CW").ToUpperInvariant() switch
|
||||
{
|
||||
"CW" => [ModeCategory.Cw],
|
||||
@@ -58,13 +83,37 @@ public sealed class UserDefinedContest : Contest
|
||||
|
||||
public string SentExchangeFor(StationInfo me) => file.Text("DefaultContestExchange", "599");
|
||||
|
||||
public int PointsFor(QsoContext qso) => points.For(qso);
|
||||
public int PointsFor(QsoContext qso)
|
||||
{
|
||||
if (!IsWorkable(qso))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
string sent = SentExchange(qso);
|
||||
double scored = points.For(qso, sent) * pointsMultiplier.For(qso);
|
||||
return (int)Math.Round(scored) + bonusPoints.For(qso, sent);
|
||||
}
|
||||
|
||||
/// What this station sends: the exchange the operator set up for the
|
||||
/// entry, and the contest's own default until they have.
|
||||
private string SentExchange(QsoContext qso) =>
|
||||
qso.Entry.SentExchange.Length > 0 ? qso.Entry.SentExchange : SentExchangeFor(qso.Me);
|
||||
|
||||
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso) =>
|
||||
multipliers.Select(m => m.For(qso)).OfType<Multiplier>().ToList();
|
||||
IsWorkable(qso)
|
||||
? multipliers.Select(m => m.For(qso)).OfType<Multiplier>().ToList()
|
||||
: [];
|
||||
|
||||
public int TotalScore(ScoreTally tally) =>
|
||||
multipliers.Count == 0 ? tally.Points : tally.Points * tally.TotalMultipliers;
|
||||
/// A contest whose multipliers are all weighted zero scores its points,
|
||||
/// and one that counts multipliers scores points times at least one, so a
|
||||
/// log with no multiplier worked yet is not worth nothing.
|
||||
public int TotalScore(ScoreTally tally, ContestEntry entry)
|
||||
{
|
||||
int total = multipliers.Any(m => m.Weight != 0)
|
||||
? tally.Points * Math.Max(1, tally.TotalMultipliers)
|
||||
: tally.Points;
|
||||
return (int)Math.Round(total * pointsMultiplier.ForEntry(entry));
|
||||
}
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
@@ -79,6 +128,34 @@ public sealed class UserDefinedContest : Contest
|
||||
new CabrilloField(ReceivedExchangePart(qso), 6),
|
||||
]);
|
||||
|
||||
/// `IsWorkable` names the stations the contest counts: a continent, my own
|
||||
/// country, everything but my own country, or a list of country prefixes. A
|
||||
/// station outside it scores nothing and brings no multiplier.
|
||||
private bool IsWorkable(QsoContext qso)
|
||||
{
|
||||
if (workableStations.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return workableStations.Any(rule => rule.ToUpperInvariant() switch
|
||||
{
|
||||
"ANY" => true,
|
||||
"MYCONTINENTONLY" => qso.IsSameContinent,
|
||||
"MYCOUNTRYONLY" => qso.IsSameCountry,
|
||||
"EXCEPTMYCOUNTRY" => !qso.IsSameCountry,
|
||||
"NAONLY" => Continent(qso, "NA"),
|
||||
"SAONLY" => Continent(qso, "SA"),
|
||||
"EUONLY" => Continent(qso, "EU"),
|
||||
"ASIAONLY" => Continent(qso, "AS"),
|
||||
"AFONLY" => Continent(qso, "AF"),
|
||||
"OCONLY" => Continent(qso, "OC"),
|
||||
_ => rule.Equals(qso.CountryPrefix, StringComparison.OrdinalIgnoreCase),
|
||||
});
|
||||
}
|
||||
|
||||
private static bool Continent(QsoContext qso, string continent) =>
|
||||
continent.Equals(qso.Continent, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private string SentExchangePart(Qso qso) =>
|
||||
HasSerialNumbers ? $"{qso.SentNumber:000}" : file.Text("DefaultContestExchange");
|
||||
|
||||
@@ -88,29 +165,21 @@ public sealed class UserDefinedContest : Contest
|
||||
: qso.ReceivedNumber > 0 ? $"{qso.ReceivedNumber:000}"
|
||||
: qso.MiscText;
|
||||
|
||||
/// `NumMults` caps how many of the three the contest counts. A file that
|
||||
/// leaves it out counts every `MultSqlString` it defines.
|
||||
private static IReadOnlyList<UdcMultiplier> ReadMultipliers(UdcFile file)
|
||||
{
|
||||
UdcMultiplierScope shared = (UdcMultiplierScope)file.Number("IsMultPer", 0);
|
||||
List<UdcMultiplier> found = [];
|
||||
for (int index = 1; index <= 3; index++)
|
||||
{
|
||||
string source = file.Text(index == 1 ? "MultSqlString" : $"MultSqlString{index}");
|
||||
if (source.Length == 0)
|
||||
UdcMultiplier multiplier = new(index, file, shared);
|
||||
if (multiplier.IsDefined)
|
||||
{
|
||||
continue;
|
||||
found.Add(multiplier);
|
||||
}
|
||||
UdcMultiplierScope scope = (UdcMultiplierScope)file.Number($"IsMult{index}Per", 0);
|
||||
if (scope == UdcMultiplierScope.None)
|
||||
{
|
||||
scope = shared == UdcMultiplierScope.None ? UdcMultiplierScope.PerBand : shared;
|
||||
}
|
||||
found.Add(new UdcMultiplier(
|
||||
index,
|
||||
source,
|
||||
scope,
|
||||
file.List(index == 1 ? "CountMultOnlyFor" : $"CountMultOnlyFor{index}")));
|
||||
}
|
||||
return found;
|
||||
return found.Take(file.Number("NumMults", found.Count)).ToList();
|
||||
}
|
||||
|
||||
/// `EntryWindowInfo` names the boxes; `FrameText` labels them. Only the
|
||||
|
||||
34
src/Nonemm.Core/ContestEntry.cs
Normal file
34
src/Nonemm.Core/ContestEntry.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// The entry the operator declared: the categories the sponsor's header asks
|
||||
/// for, and the exchange this station sends. Contests score by these — a QRP
|
||||
/// entry can be worth a multiple of what the same contacts score at high
|
||||
/// power, and a contest can pay by what the station itself sends.
|
||||
///
|
||||
/// The values are the Cabrillo category names, which is what the log file
|
||||
/// stores and what N1MM compares against.
|
||||
public sealed record ContestEntry
|
||||
{
|
||||
public string OperatorCategory { get; init; } = "SINGLE-OP";
|
||||
|
||||
public string BandCategory { get; init; } = "ALL";
|
||||
|
||||
public string PowerCategory { get; init; } = "HIGH";
|
||||
|
||||
public string ModeCategory { get; init; } = "";
|
||||
|
||||
public string OverlayCategory { get; init; } = "";
|
||||
|
||||
public string StationCategory { get; init; } = "";
|
||||
|
||||
public string AssistedCategory { get; init; } = "NON-ASSISTED";
|
||||
|
||||
public string TransmitterCategory { get; init; } = "ONE";
|
||||
|
||||
public string TimeCategory { get; init; } = "";
|
||||
|
||||
/// What this station sends, as the operator typed it into the contest
|
||||
/// setup. Empty until a contest is open, and then whatever N1MM would put
|
||||
/// in the sent exchange box.
|
||||
public string SentExchange { get; init; } = "";
|
||||
}
|
||||
@@ -30,7 +30,7 @@ public sealed class ContestSession
|
||||
Contest = contest;
|
||||
Instance = instance;
|
||||
Me = me;
|
||||
Log = new ContestLog(contest, me, countries);
|
||||
Log = new ContestLog(contest, me, countries, instance.Entry);
|
||||
Log.Restore(store.Qsos(instance.ContestNumber));
|
||||
Editor = new QsoEditor(contest, me, countries);
|
||||
SentNumber = NextSentNumber();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Storage;
|
||||
|
||||
/// One running of a contest in the log: the entry categories, the sent exchange
|
||||
@@ -48,4 +50,19 @@ public sealed record ContestInstance
|
||||
public string Soapbox { get; init; } = "";
|
||||
|
||||
public long ClaimedScore { get; init; }
|
||||
|
||||
/// The entry as the contest rules read it.
|
||||
public ContestEntry Entry => new()
|
||||
{
|
||||
OperatorCategory = OperatorCategory,
|
||||
BandCategory = BandCategory,
|
||||
PowerCategory = PowerCategory,
|
||||
ModeCategory = ModeCategory,
|
||||
OverlayCategory = OverlayCategory,
|
||||
StationCategory = StationCategory,
|
||||
AssistedCategory = AssistedCategory,
|
||||
TransmitterCategory = TransmitterCategory,
|
||||
TimeCategory = TimeCategory,
|
||||
SentExchange = SentExchange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,10 +111,12 @@ internal static class QsoColumns
|
||||
IsClaimed = Flag(row, "CLAIMEDQSO"),
|
||||
};
|
||||
|
||||
/// N1MM writes a single space into a column it has nothing for, so an
|
||||
/// empty value has to be read as empty rather than as one space.
|
||||
private static string Text(SqliteDataReader row, string column)
|
||||
{
|
||||
int at = row.GetOrdinal(column);
|
||||
return row.IsDBNull(at) ? "" : row.GetString(at);
|
||||
return row.IsDBNull(at) ? "" : row.GetString(at).Trim();
|
||||
}
|
||||
|
||||
private static double Number(SqliteDataReader row, string column)
|
||||
|
||||
@@ -12,7 +12,7 @@ public class ArrlDxTests
|
||||
public void DxWorkingWvieScoresThree()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Equal(3, log.Judge(TestLog.Contact("K1ABC", exchange: "CT")).Points);
|
||||
Assert.Equal(3, log.Judge(TestLog.Contact("K1ABC", section: "CT")).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -33,8 +33,8 @@ public class ArrlDxTests
|
||||
public void DxCountsStatesAndProvinces()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Equal("CT", log.Judge(TestLog.Contact("K1ABC", exchange: "CT")).NewMultipliers.Single().Value);
|
||||
Assert.Empty(log.Judge(TestLog.Contact("K1ABC", exchange: "XX")).NewMultipliers);
|
||||
Assert.Equal("CT", log.Judge(TestLog.Contact("K1ABC", section: "CT")).NewMultipliers.Single().Value);
|
||||
Assert.Empty(log.Judge(TestLog.Contact("K1ABC", section: "XX")).NewMultipliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -48,8 +48,28 @@ public class ArrlDxTests
|
||||
public void MultipliersCountOncePerBand()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
log.Add(TestLog.Contact("K1ABC", exchange: "CT"));
|
||||
Assert.Empty(log.Judge(TestLog.Contact("K2ABC", exchange: "CT")).NewMultipliers);
|
||||
Assert.Single(log.Judge(TestLog.Contact("K2ABC", 7_025, exchange: "CT")).NewMultipliers);
|
||||
log.Add(TestLog.Contact("K1ABC", section: "CT"));
|
||||
Assert.Empty(log.Judge(TestLog.Contact("K2ABC", section: "CT")).NewMultipliers);
|
||||
Assert.Single(log.Judge(TestLog.Contact("K2ABC", 7_025, section: "CT")).NewMultipliers);
|
||||
}
|
||||
|
||||
/// A Canadian station sends the postal code or the older abbreviation, and
|
||||
/// Newfoundland and Labrador is one province either way. N1MM counts `NF`
|
||||
/// and `LB` as two multipliers, which is two for one province.
|
||||
[Fact]
|
||||
public void NewfoundlandAndLabradorCountOnce()
|
||||
{
|
||||
ContestLog log = new(new ArrlDx(ModeCategory.Phone), TestLog.Germany, TestLog.CountryFile);
|
||||
log.Add(TestLog.Contact("VO1XYZ", section: "NF"));
|
||||
Assert.Empty(log.Judge(TestLog.Contact("VO2XYZ", section: "LB")).NewMultipliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheOlderQuebecAbbreviationIsQuebec()
|
||||
{
|
||||
ContestLog log = new(new ArrlDx(ModeCategory.Phone), TestLog.Germany, TestLog.CountryFile);
|
||||
Assert.Equal(
|
||||
"QC",
|
||||
log.Judge(TestLog.Contact("VA2XYZ", section: "QB")).NewMultipliers.Single().Value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,4 +90,24 @@ public class CqWorldWideRttyTests
|
||||
Assert.Equal(ExchangeSlot.Section, fields[2].Slot);
|
||||
Assert.False(fields[2].IsRequired);
|
||||
}
|
||||
|
||||
/// The Canadian areas this contest counts are its own list, which splits
|
||||
/// Newfoundland from Labrador.
|
||||
[Theory]
|
||||
[InlineData("NF")]
|
||||
[InlineData("LB")]
|
||||
public void TheContestsOwnCanadianAreasCount(string area) =>
|
||||
Assert.Contains(
|
||||
LogFor(TestLog.Germany).Judge(Contact("VO1XYZ", 5, area)).NewMultipliers,
|
||||
m => m.Index == 3 && m.Value == area);
|
||||
|
||||
/// A maritime mobile station belongs to no country. CQ WW counts it for the
|
||||
/// zone alone, and the contact still scores by continent.
|
||||
[Fact]
|
||||
public void MaritimeMobileScoresButBringsNoCountry()
|
||||
{
|
||||
Verdict verdict = LogFor(TestLog.Germany).Judge(Contact("IK2XYZ/MM", 15));
|
||||
Assert.Equal(2, verdict.Points);
|
||||
Assert.DoesNotContain(verdict.NewMultipliers, m => m.Index == 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,4 +66,16 @@ public class CqWpxTests
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Empty(log.Judge(TestLog.Contact("DL1ABC/MM")).NewMultipliers);
|
||||
}
|
||||
|
||||
/// A tuning carrier logged by accident, or any contact in a mode the
|
||||
/// contest does not run, scores nothing. The prefix still counts, which is
|
||||
/// what N1MM logs.
|
||||
[Fact]
|
||||
public void AContactInAnotherModeScoresNothingAndKeepsItsMultiplier()
|
||||
{
|
||||
ContestLog log = new(new CqWpx(ModeCategory.Phone), TestLog.Germany, TestLog.CountryFile);
|
||||
Verdict verdict = log.Judge(TestLog.Contact("IK2XYZ", mode: Modes.Rtty, number: 5));
|
||||
Assert.Equal(0, verdict.Points);
|
||||
Assert.Single(verdict.NewMultipliers);
|
||||
}
|
||||
}
|
||||
|
||||
264
tests/Nonemm.Contests.Tests/UdcScoringTests.cs
Normal file
264
tests/Nonemm.Contests.Tests/UdcScoringTests.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using Nonemm.Contests.Udc;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Tests;
|
||||
|
||||
/// The scoring settings a `.udc` file can carry: the points conditions, the
|
||||
/// points multipliers, the bonus, and where a multiplier comes from.
|
||||
public class UdcScoringTests
|
||||
{
|
||||
private static UserDefinedContest Contest(params string[] settings) =>
|
||||
new(UdcFile.Parse("[Contest]\nName=SCORETEST\n" + string.Join("\n", settings)));
|
||||
|
||||
private static ContestLog LogFor(
|
||||
UserDefinedContest contest,
|
||||
StationInfo? me = null,
|
||||
ContestEntry? entry = null) =>
|
||||
new(contest, me ?? TestLog.Germany, TestLog.CountryFile, entry);
|
||||
|
||||
private static int Points(UserDefinedContest contest, Qso qso, StationInfo? me = null) =>
|
||||
LogFor(contest, me).Judge(qso).Points;
|
||||
|
||||
[Theory]
|
||||
[InlineData("CT", 5)]
|
||||
[InlineData("MA", 1)]
|
||||
public void SectIsMatchesTheReceivedSection(string section, int expected) =>
|
||||
Assert.Equal(
|
||||
expected,
|
||||
Points(
|
||||
Contest("PointsPerContact=SectIs_CT, 5, OtherContinent, 1"),
|
||||
TestLog.Contact("K1ABC", section: section)));
|
||||
|
||||
[Fact]
|
||||
public void ExchIsNumSeparatesANumberFromAWord() =>
|
||||
Assert.Equal(
|
||||
3,
|
||||
Points(
|
||||
Contest("PointsPerContact=ExchIsNum, 3, ExchIsNotNum, 7"),
|
||||
TestLog.Contact("K1ABC", exchange: "129")));
|
||||
|
||||
[Fact]
|
||||
public void ExchIsEmptyScoresTheContactWithNoExchange() =>
|
||||
Assert.Equal(
|
||||
2,
|
||||
Points(Contest("PointsPerContact=ExchIs_Empty, 2, ExchIsNum, 9"), TestLog.Contact("K1ABC")));
|
||||
|
||||
[Fact]
|
||||
public void AStarScoresTheNumberInTheExchangeBox() =>
|
||||
Assert.Equal(
|
||||
42,
|
||||
Points(Contest("PointsPerContact=Exchange, *"), TestLog.Contact("K1ABC", exchange: "42")));
|
||||
|
||||
[Fact]
|
||||
public void MyCqZoneScoresAStationInMyOwnZone() =>
|
||||
Assert.Equal(
|
||||
1,
|
||||
Points(Contest("PointsPerContact=MyCQZone, 1, OtherContinent, 4"), TestLog.Contact("DL9XYZ")));
|
||||
|
||||
/// `SAMECONTINENT+160` is the same continent on one band only.
|
||||
[Fact]
|
||||
public void AContinentConditionCanCarryABand()
|
||||
{
|
||||
UserDefinedContest contest = Contest("PointsPerContact=SameContinent+160, 6, SameContinent, 2");
|
||||
Assert.Equal(6, Points(contest, TestLog.Contact("IK2XYZ", 1_830)));
|
||||
Assert.Equal(2, Points(contest, TestLog.Contact("IK2XYZ")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PointsMultByBandScalesWhatTheContactScores()
|
||||
{
|
||||
UserDefinedContest contest = Contest("PointsPerContact=3", "PointsMultByBand=160M, 3, 20M, 1");
|
||||
Assert.Equal(9, Points(contest, TestLog.Contact("IK2XYZ", 1_830)));
|
||||
Assert.Equal(3, Points(contest, TestLog.Contact("IK2XYZ")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PointsMultAtTimeGmtScalesInsideItsWindow()
|
||||
{
|
||||
UserDefinedContest contest = Contest("PointsPerContact=2", "PointsMultAtTimeGMT=2300, 0300, 2");
|
||||
DateTime midnight = new(2026, 5, 30, 0, 30, 0, DateTimeKind.Utc);
|
||||
Assert.Equal(4, Points(contest, TestLog.Contact("IK2XYZ", at: midnight)));
|
||||
Assert.Equal(2, Points(contest, TestLog.Contact("IK2XYZ")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BonusPointsAreAddedToWhatTheContactScores() =>
|
||||
Assert.Equal(
|
||||
26,
|
||||
Points(
|
||||
Contest("PointsPerContact=1", "BonusPoints=IK2XYZ, 25"),
|
||||
TestLog.Contact("IK2XYZ")));
|
||||
|
||||
/// A contest can pay the bonus for a new multiplier, so the multiplier has
|
||||
/// to be worked out before the points are.
|
||||
[Fact]
|
||||
public void BonusPointsCanBePaidForANewMultiplier()
|
||||
{
|
||||
UserDefinedContest contest = Contest(
|
||||
"PointsPerContact=1",
|
||||
"MultSqlString=CountryPrefix",
|
||||
"IsMultPer=1",
|
||||
"BonusPoints=IsMult1, 10");
|
||||
ContestLog log = LogFor(contest);
|
||||
Assert.Equal(11, log.Judge(TestLog.Contact("IK2XYZ")).Points);
|
||||
log.Add(TestLog.Contact("IK2XYZ"));
|
||||
Assert.Equal(1, log.Judge(TestLog.Contact("IK3XYZ")).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADistanceTableScoresByTheRangeTheContactFallsIn()
|
||||
{
|
||||
UserDefinedContest contest = Contest("PointsPerContact=0/100/7");
|
||||
StationInfo me = TestLog.Germany with { GridSquare = "JO60" };
|
||||
Qso near = TestLog.Contact("DL9XYZ") with { GridSquare = "JO60" };
|
||||
Qso far = TestLog.Contact("JA1XYZ") with { GridSquare = "PM95" };
|
||||
Assert.Equal(7, Points(contest, near, me));
|
||||
Assert.True(Points(contest, far, me) > 1_000);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MultSqlString=2LPREFIX", "IK")]
|
||||
[InlineData("MultSqlString=LastLetter", "Z")]
|
||||
[InlineData("MultSqlString=EU_Country", "I")]
|
||||
public void TheMultiplierValueComesFromTheSource(string setting, string expected)
|
||||
{
|
||||
UserDefinedContest contest = Contest(setting, "IsMultPer=4");
|
||||
Verdict verdict = LogFor(contest).Judge(TestLog.Contact("IK2XYZ"));
|
||||
Assert.Equal(expected, Assert.Single(verdict.NewMultipliers).Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACountryOnAnotherContinentBringsNoContinentMultiplier() =>
|
||||
Assert.Empty(
|
||||
LogFor(Contest("MultSqlString=EU_Country", "IsMultPer=4"))
|
||||
.Judge(TestLog.Contact("JA1XYZ"))
|
||||
.NewMultipliers);
|
||||
|
||||
[Fact]
|
||||
public void CountMultOnlyForTakesAContinentAsWellAsACountry()
|
||||
{
|
||||
UserDefinedContest contest = Contest(
|
||||
"MultSqlString=CountryPrefix",
|
||||
"IsMultPer=4",
|
||||
"CountMultOnlyFor=EU");
|
||||
Assert.Single(LogFor(contest).Judge(TestLog.Contact("IK2XYZ")).NewMultipliers);
|
||||
Assert.Empty(LogFor(contest).Judge(TestLog.Contact("JA1XYZ")).NewMultipliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoNotCountMultOnlyForLeavesThoseStationsOut() =>
|
||||
Assert.Empty(
|
||||
LogFor(Contest("MultSqlString=CountryPrefix", "IsMultPer=4", "DoNotCountMultOnlyFor=JA"))
|
||||
.Judge(TestLog.Contact("JA1XYZ"))
|
||||
.NewMultipliers);
|
||||
|
||||
[Fact]
|
||||
public void DoNotCountMeAsMultLeavesOutMyOwnCountry() =>
|
||||
Assert.Empty(
|
||||
LogFor(Contest("MultSqlString=CountryPrefix", "IsMultPer=4", "DoNotCountMeAsMult=True"))
|
||||
.Judge(TestLog.Contact("DL9XYZ"))
|
||||
.NewMultipliers);
|
||||
|
||||
[Fact]
|
||||
public void MultMultSaysWhatAMultiplierIsWorth()
|
||||
{
|
||||
ContestLog log = LogFor(Contest(
|
||||
"PointsPerContact=1",
|
||||
"MultSqlString=CountryPrefix",
|
||||
"IsMultPer=4",
|
||||
"MultMult=2"));
|
||||
log.Add(TestLog.Contact("JA1XYZ"));
|
||||
log.Add(TestLog.Contact("IK2XYZ"));
|
||||
Assert.Equal(8, log.TotalScore);
|
||||
}
|
||||
|
||||
/// A multiplier weighted zero is still worked, and adds nothing.
|
||||
[Fact]
|
||||
public void MultMultZeroLeavesTheScoreAtThePoints()
|
||||
{
|
||||
ContestLog log = LogFor(Contest(
|
||||
"PointsPerContact=3",
|
||||
"MultSqlString=CountryPrefix",
|
||||
"IsMultPer=4",
|
||||
"MultMult=0"));
|
||||
log.Add(TestLog.Contact("JA1XYZ"));
|
||||
Assert.Equal(3, log.TotalScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PowerMultScalesTheWholeScoreByTheEntryCategory()
|
||||
{
|
||||
UserDefinedContest contest = Contest("PointsPerContact=2", "PowerMult=QRP, 3, LP, 2, HP, 1");
|
||||
ContestLog qrp = LogFor(contest, entry: new ContestEntry { PowerCategory = "QRP" });
|
||||
ContestLog low = LogFor(contest, entry: new ContestEntry { PowerCategory = "LOW" });
|
||||
qrp.Add(TestLog.Contact("JA1XYZ"));
|
||||
low.Add(TestLog.Contact("JA1XYZ"));
|
||||
Assert.Equal(6, qrp.TotalScore);
|
||||
Assert.Equal(4, low.TotalScore);
|
||||
}
|
||||
|
||||
/// `PointsMultByCategory` is about the station being worked, not the entry:
|
||||
/// N1MM scales only a callsign signing QRP.
|
||||
[Fact]
|
||||
public void PointsMultByCategoryScalesAStationSigningQrp()
|
||||
{
|
||||
UserDefinedContest contest = Contest("PointsPerContact=2", "PointsMultByCategory=/QRP, 3");
|
||||
Assert.Equal(6, Points(contest, TestLog.Contact("JA1XYZ/QRP")));
|
||||
Assert.Equal(2, Points(contest, TestLog.Contact("JA1XYZ")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MyExchangeComparesWhatThisStationSends()
|
||||
{
|
||||
UserDefinedContest contest = Contest("PointsPerContact=MyExchange, 1, OtherContinent, 5");
|
||||
ContestEntry entry = new() { SentExchange = "14" };
|
||||
Assert.Equal(
|
||||
1,
|
||||
LogFor(contest, entry: entry).Judge(TestLog.Contact("JA1XYZ", section: "14")).Points);
|
||||
Assert.Equal(
|
||||
5,
|
||||
LogFor(contest, entry: entry).Judge(TestLog.Contact("JA1XYZ", section: "15")).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumMultsCapsHowManyAreCounted()
|
||||
{
|
||||
UserDefinedContest contest = Contest(
|
||||
"MultSqlString=CountryPrefix",
|
||||
"MultSqlString2=Continent",
|
||||
"IsMultPer=4",
|
||||
"NumMults=1");
|
||||
Assert.Single(LogFor(contest).Judge(TestLog.Contact("JA1XYZ")).NewMultipliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DupeSqlStringMakesASecondSectionADifferentContact()
|
||||
{
|
||||
UserDefinedContest contest = Contest("DupeType=1", "DupeSqlString=1");
|
||||
ContestLog log = LogFor(contest);
|
||||
log.Add(TestLog.Contact("K1ABC", section: "CT"));
|
||||
Assert.False(log.Judge(TestLog.Contact("K1ABC", section: "MA")).IsDupe);
|
||||
Assert.True(log.Judge(TestLog.Contact("K1ABC", section: "CT")).IsDupe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsWorkableLeavesOutTheStationsTheContestDoesNotCount()
|
||||
{
|
||||
UserDefinedContest contest = Contest(
|
||||
"PointsPerContact=3",
|
||||
"MultSqlString=CountryPrefix",
|
||||
"IsMultPer=4",
|
||||
"IsWorkable=EUonly");
|
||||
Verdict outside = LogFor(contest).Judge(TestLog.Contact("JA1XYZ"));
|
||||
Assert.Equal(0, outside.Points);
|
||||
Assert.Empty(outside.NewMultipliers);
|
||||
Assert.Equal(3, Points(contest, TestLog.Contact("IK2XYZ")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZoneTypeIaruPutsTheContestOnItuZones()
|
||||
{
|
||||
Assert.True(Contest("ZoneType=IARU").UsesItuZones);
|
||||
Assert.False(Contest("ZoneType=CQ").UsesItuZones);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user