Files
Nonemm/src/Nonemm.Contests/Udc/UdcMultipliers.cs
ericek111 38e9010802 Let the published .udc files score the contests they are written for
I wrote SA 10 and OK/OM DX SSB in code after two failed attempts at the
download. The download works — the page posts a nonce with the form — and the
files say I got both contests wrong.

SA 10 is deleted from the code. Its file scores the station's log to the
contact, where the code had the wrong Cabrillo name and counted its multipliers
once in the contest rather than once per mode.

OK/OM DX now covers the CW running only, which is what N1MM's class is for and
what N1MM's own documentation says: the SSB running has different rules and a
pair of .udc files, one for each side. The CW rules go back to the class, so
Czechia and Slovakia are one side of the contest again. With the SSB files in
place both of the station's SSB logs score to the contact.

Reading those files turned up four things in the .udc reader: a file whose
extension is upper case was passed over, `Country` was not read as a
multiplier source, `OtherCountry` was not a points condition, and a section
multiplier counted the serial numbers the other side of a contest sends.

All Asian stays in code — N1MM has a class for it after all — with the Cabrillo
name and the band table it uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
2026-08-31 10:00:21 +00:00

154 lines
6.3 KiB
C#

using Nonemm.Core;
namespace Nonemm.Contests.Udc;
/// One of a user-defined contest's up-to-three multipliers: where its value
/// 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 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, UdcFile file, UdcMultiplierScope shared)
{
this.index = index;
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)
{
string? value = ValueFor(qso);
if (scope == UdcMultiplierScope.None || string.IsNullOrEmpty(value))
{
return null;
}
return Counts(qso, value) ? new Multiplier(index, value, ScopeKey(qso), weight) : null;
}
/// `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" or "COUNTRY" => 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" => SectionName(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" => 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),
"CONTINENT" => qso.Continent,
"FIRSTQSO" => "first",
_ => 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 ?? "",
UdcMultiplierScope.PerMode => qso.ModeCategory.ToString(),
UdcMultiplierScope.PerBandAndMode => $"{qso.Band?.Name}|{qso.ModeCategory}",
_ => "",
};
private static string Upper(string text) => text.Trim().ToUpperInvariant();
/// A section multiplier ignores a value that is all digits. N1MM checks the
/// received exchange against the section list the contest names, and a file
/// whose section column also carries the serial numbers the other side of
/// the contest sends — OK/OM DX SSB is one — would otherwise count every
/// serial number as a multiplier. The section lists themselves are not held
/// here.
private static string? SectionName(string text)
{
string section = Upper(text);
return section.Length > 0 && section.All(char.IsAsciiDigit) ? null : section;
}
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();
return trimmed.Length < length ? null : trimmed[..length];
}
}