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 onlyFor; private readonly IReadOnlyList notFor; private readonly IReadOnlyList 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]; } }