using Nonemm.Core; namespace Nonemm.Contests.Udc; /// A contest read from a `.udc` file. The subset of N1MM's settings this reads /// covers the exchange, the dupe rule, points and up to three multipliers; /// settings it does not read are ignored rather than guessed at. public sealed class UserDefinedContest : Contest { private readonly UdcFile file; private readonly UdcPoints points; private readonly UdcPoints bonusPoints; private readonly UdcPointsMultiplier pointsMultiplier; private readonly IReadOnlyList multipliers; private readonly IReadOnlyList exchangeFields; private readonly IReadOnlyList workableStations; private readonly UdcCabrillo cabrillo; private readonly UdcCabrilloFormat cabrilloFormat; 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"); cabrillo = new UdcCabrillo(file.Text("CabrilloString")); cabrilloFormat = new UdcCabrilloFormat(file.Number("CabrilloFormat", 1)); } public static UserDefinedContest Load(string path) => new(UdcFile.Parse(File.ReadAllText(path))); public string Name => file.Text("Name").ToUpperInvariant(); public string DisplayName => file.Text("DisplayName", Name); public string CabrilloName => file.Text("CabrilloName", Name); public string CabrilloVersion => file.Text("CabrilloVersion", "3.0"); public IReadOnlyList ExchangeFieldsFor(StationInfo me) => exchangeFields; public IReadOnlyList MultiplierNames => new[] { "Multiplier1Name", "Multiplier2Name", "Multiplier3Name" } .Take(multipliers.Count) .Select(key => file.Text(key)) .Where(name => name.Length > 0) .ToList(); public DupeScope DupeScope => file.Number("DupeType", 2) switch { 1 => DupeScope.Once, 3 => DupeScope.PerBandAndMode, 4 => DupeScope.Never, _ => 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 Modes => file.Text("Mode", "CW").ToUpperInvariant() switch { "CW" => [ModeCategory.Cw], "SSB" => [ModeCategory.Phone], "RTTY" => [ModeCategory.Digital], _ => [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], }; public string SentExchangeFor(StationInfo me) => file.Text("DefaultContestExchange", "599"); 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); /// The exchange values of one contact, in the order the file lists the /// entry boxes. The report is left out because no numbered layout has a /// column for it. private string ReceivedExchange(Qso qso) => string.Join( ' ', exchangeFields .Where(field => field.Slot != ExchangeSlot.ReceivedReport) .Select(field => QsoExchange.ValueOf(qso, field.Slot)) .Where(value => value.Length > 0)); public IReadOnlyList MultipliersFor(QsoContext qso) => IsWorkable(qso) ? multipliers.Select(m => m.For(qso)).OfType().ToList() : []; /// 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)); } /// A sponsor who asks for columns of their own gets them two ways: /// `CabrilloString` names every column, and a numbered `CabrilloFormat` /// names one of the four layouts N1MM has built in. A file with neither /// gets the usual line: each station, its report and its exchange. public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => cabrillo.IsDefined ? new CabrilloExchange(cabrillo.Fields(qso, me, entry), []) { AddsTransmitter = cabrillo.HasTransmitter, } : cabrilloFormat.IsNumbered ? cabrilloFormat.Line( qso, me, entry.SentExchange.Length > 0 ? entry.SentExchange : SentExchangeFor(me), ReceivedExchange(qso)) : new CabrilloExchange( [ new CabrilloField(me.Callsign, 13), new CabrilloField(qso.SentReport, 3), new CabrilloField(SentExchangePart(qso), 6), ], [ new CabrilloField(qso.Call.Text, 13), new CabrilloField(qso.ReceivedReport, 3), 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"); private static string ReceivedExchangePart(Qso qso) => qso.Exchange1.Length > 0 ? qso.Exchange1 : qso.Section.Length > 0 ? qso.Section : 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 ReadMultipliers(UdcFile file) { UdcMultiplierScope shared = (UdcMultiplierScope)file.Number("IsMultPer", 0); List found = []; for (int index = 1; index <= 3; index++) { UdcMultiplier multiplier = new(index, file, shared); if (multiplier.IsDefined) { found.Add(multiplier); } } return found.Take(file.Number("NumMults", found.Count)).ToList(); } /// `EntryWindowInfo` names the boxes; `FrameText` labels them. Only the /// received boxes go in the exchange, because the sent side is fixed for /// the whole contest. private static IReadOnlyList ReadExchangeFields(UdcFile file) { string[] labels = file.Text("FrameText").Split(' ', StringSplitOptions.RemoveEmptyEntries); List fields = []; int labelAt = 0; foreach (string box in file.List("EntryWindowInfo").Where(p => !p.All(char.IsAsciiDigit))) { ExchangeField? field = BoxToField(box, labels.Length > labelAt ? labels[labelAt] : box); labelAt++; if (field is not null) { fields.Add(field); } } return fields.Count > 0 ? fields : [new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report)]; } /// The names are N1MM's `LogItem.LogItemNameType` members, which is what a /// published `.udc` file holds. `SectText` is not one of them but files in /// the wild use it. The sent-side boxes — `SNTText`, `SntNrText` — are not /// exchange boxes here and fall through. private static ExchangeField? BoxToField(string box, string label) => box.ToUpperInvariant() switch { "RCVTEXT" => new ExchangeField(label, ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), "RCVNRTEXT" => new ExchangeField(label, ExchangeSlot.SerialNumber, ExchangeFieldKind.Number), "EXCHANGE1TEXT" => new ExchangeField(label, ExchangeSlot.Exchange1, ExchangeFieldKind.Text) { Width = 6 }, "SECTIONTEXT" or "SECTTEXT" => new ExchangeField(label, ExchangeSlot.Section, ExchangeFieldKind.ArrlSection), "CQZONETEXT" => new ExchangeField(label, ExchangeSlot.Zone, ExchangeFieldKind.CqZone), "POWERTEXT" => new ExchangeField(label, ExchangeSlot.Power, ExchangeFieldKind.Power), "GRIDSQUARETEXT" => new ExchangeField(label, ExchangeSlot.GridSquare, ExchangeFieldKind.Grid), "NAMETEXT" => new ExchangeField(label, ExchangeSlot.Name, ExchangeFieldKind.Text) { Width = 10 }, "MISCTEXT" => new ExchangeField(label, ExchangeSlot.MiscText, ExchangeFieldKind.Text) { Width = 8 }, "COMMENTTEXT" => new ExchangeField(label, ExchangeSlot.Comment, ExchangeFieldKind.Text, IsRequired: false) { Width = 20 }, _ => null, }; }