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:
2026-08-31 08:33:40 +00:00
parent 9424c488ea
commit 04568d0ca3
28 changed files with 1088 additions and 142 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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);

View File

@@ -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) =>

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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);

View File

@@ -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(

View File

@@ -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(

View File

@@ -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.

View File

@@ -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

View 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);
}

View File

@@ -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();

View File

@@ -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,
};
foreach ((int low, int high, int points) in ranges)
{
if (km >= low && km <= high)
{
return points;
}
}
return null;
}
private static bool MatchesContinent(QsoContext qso, string condition) => condition.ToUpperInvariant() switch
private void ReadRanges(string setting)
{
"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,
};
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();
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));
}
}
}
}

View 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);
}

View File

@@ -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

View 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; } = "";
}

View File

@@ -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();

View File

@@ -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,
};
}

View File

@@ -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)

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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);
}
}