From e3fa9795849d86bf8aa26ec5787288395b9e565a Mon Sep 17 00:00:00 2001 From: ericek111 Date: Mon, 31 Aug 2026 13:31:25 +0000 Subject: [PATCH] Read the .udc CallHist multiplier and BonusPoints2 MultSqlString = CallHist counts every station the call history file lists, once per callsign, which is how N1MM dedups it. The file reaches the scoring code through ContestLog.History, and a QsoContext now carries it; without a file loaded nothing counts. BonusPoints2 = +50, calls.txt reads the callsigns from the support-files folder and adds 50 to the contact, *2 doubles it and a plain 50 scores 50 instead. The folder comes from the registry, which the app passes when it reads the .udc files. N1MM's other form, where the file is grids.txt and the bonus is looked up by grid square, is not read. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD --- docs/unfinished.md | 9 ++- src/Nonemm.App/AppSession.cs | 7 +- src/Nonemm.Contests/ContestLog.cs | 10 ++- src/Nonemm.Contests/ContestRegistry.cs | 17 +++-- src/Nonemm.Contests/QsoContext.cs | 5 ++ src/Nonemm.Contests/Udc/UdcBonusCalls.cs | 70 +++++++++++++++++++ src/Nonemm.Contests/Udc/UdcMultipliers.cs | 10 ++- src/Nonemm.Contests/Udc/UserDefinedContest.cs | 10 ++- src/Nonemm.Session/ContestSession.cs | 2 +- .../Nonemm.Contests.Tests/UdcScoringTests.cs | 62 ++++++++++++++++ 10 files changed, 184 insertions(+), 18 deletions(-) create mode 100644 src/Nonemm.Contests/Udc/UdcBonusCalls.cs diff --git a/docs/unfinished.md b/docs/unfinished.md index 7adbf83..7046271 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -108,11 +108,10 @@ 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. -`.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` and `QsoErrorString`, which -are about the windows rather than the score. `GenericPrintString`, which is the -layout of a printed log rather than a Cabrillo one. +`.udc` settings that are still passed over. `MultiplierBands` and +`QsoErrorString`, which are about the windows rather than the score. +`GenericPrintString`, which is the layout of a printed log rather than a +Cabrillo one. `MultWindowType` is read as far as the score goes: it names the list a section multiplier is checked against. Four of N1MM's lists are held here — the ARRL diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 1ff9254..eeb90b5 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -49,7 +49,10 @@ public sealed class AppSession : IDisposable Countries = LoadCountryFile(paths.CountryFile); Calls = LoadCallDatabase(paths.CallDatabaseFile); History = LoadCallHistory(settings.CallHistoryFile); - Registry = ContestRegistry.FromFolder(paths.UserDefinedContests, out IReadOnlyList problems); + Registry = ContestRegistry.FromFolder( + paths.UserDefinedContests, + out IReadOnlyList problems, + paths.SupportFiles); UserDefinedContestProblems = problems; BandPlan = settings.ToBandPlan(); ApplySpotSettings(); @@ -520,7 +523,7 @@ public sealed class AppSession : IDisposable Countries = LoadCountryFile(Paths.CountryFile); Calls = LoadCallDatabase(Paths.CallDatabaseFile); History = LoadCallHistory(Settings.CallHistoryFile); - Registry = ContestRegistry.FromFolder(Paths.UserDefinedContests, out _); + Registry = ContestRegistry.FromFolder(Paths.UserDefinedContests, out _, Paths.SupportFiles); ApplySpotSettings(); if (Logging is not null) { diff --git a/src/Nonemm.Contests/ContestLog.cs b/src/Nonemm.Contests/ContestLog.cs index 986c23d..3c83e50 100644 --- a/src/Nonemm.Contests/ContestLog.cs +++ b/src/Nonemm.Contests/ContestLog.cs @@ -1,4 +1,5 @@ using Nonemm.Core; +using Nonemm.Core.Calls; using Nonemm.Core.Country; namespace Nonemm.Contests; @@ -31,6 +32,10 @@ public sealed class ContestLog /// change them while the contest is open, and the score follows. public ContestEntry Entry { get; set; } + /// The call history file, for a contest that counts a multiplier from it. + /// The session sets it when the operator loads one. + public CallHistory History { get; set; } = CallHistory.Empty; + public Contest Contest => contest; public IReadOnlyList Qsos => qsos; @@ -132,7 +137,10 @@ public sealed class ContestLog /// 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); + new(qso, countries?.Find(qso.Call) ?? countries?.Find(qso.Call.Station), me, Entry) + { + History = History, + }; private static Qso ApplyVerdict(Qso qso, Verdict verdict) => ApplyMultiplierFlags(qso, verdict.NewMultipliers) with { Points = verdict.Points }; diff --git a/src/Nonemm.Contests/ContestRegistry.cs b/src/Nonemm.Contests/ContestRegistry.cs index 9853e2e..1861c16 100644 --- a/src/Nonemm.Contests/ContestRegistry.cs +++ b/src/Nonemm.Contests/ContestRegistry.cs @@ -42,19 +42,23 @@ public sealed class ContestRegistry private ContestRegistry(Dictionary byName) => this.byName = byName; - public static ContestRegistry Create(IEnumerable? userDefined = null) + /// `supportFolder` is where a user-defined contest reads the files its + /// settings name, which is the folder the country file is in. + public static ContestRegistry Create( + IEnumerable? userDefined = null, + string? supportFolder = null) { Dictionary byName = BuiltIn.ToDictionary( c => c.Name, StringComparer.OrdinalIgnoreCase); foreach (UdcFile file in userDefined ?? []) { - UserDefinedContest contest = new(file); + UserDefinedContest contest = new(file, supportFolder); byName[contest.Name] = new ContestChoice( contest.Name, contest.DisplayName, contest.Modes, - _ => new UserDefinedContest(file)); + _ => new UserDefinedContest(file, supportFolder)); } return new ContestRegistry(byName); } @@ -62,7 +66,10 @@ public sealed class ContestRegistry /// Reads every `.udc` file in the folder. Published files come with the /// extension in either case, and a file that will not parse is reported /// rather than silently left out. - public static ContestRegistry FromFolder(string folder, out IReadOnlyList problems) + public static ContestRegistry FromFolder( + string folder, + out IReadOnlyList problems, + string? supportFolder = null) { List files = []; List failures = []; @@ -83,7 +90,7 @@ public sealed class ContestRegistry } } problems = failures; - return Create(files); + return Create(files, supportFolder); } public IReadOnlyList Choices => diff --git a/src/Nonemm.Contests/QsoContext.cs b/src/Nonemm.Contests/QsoContext.cs index 6d20e6a..0fa1891 100644 --- a/src/Nonemm.Contests/QsoContext.cs +++ b/src/Nonemm.Contests/QsoContext.cs @@ -1,4 +1,5 @@ using Nonemm.Core; +using Nonemm.Core.Calls; using Nonemm.Core.Country; namespace Nonemm.Contests; @@ -7,6 +8,10 @@ namespace Nonemm.Contests; /// score by country, zone or continent without looking anything up itself. public sealed record QsoContext(Qso Qso, CountryLookup? Country, StationInfo Me, ContestEntry Entry) { + /// The call history file the operator loaded, which a contest can count a + /// multiplier from. Empty when there is none. + public CallHistory History { get; init; } = CallHistory.Empty; + public Band? Band => Qso.Band; public ModeCategory ModeCategory => Qso.Mode.Category; diff --git a/src/Nonemm.Contests/Udc/UdcBonusCalls.cs b/src/Nonemm.Contests/Udc/UdcBonusCalls.cs new file mode 100644 index 0000000..17291db --- /dev/null +++ b/src/Nonemm.Contests/Udc/UdcBonusCalls.cs @@ -0,0 +1,70 @@ +namespace Nonemm.Contests.Udc; + +/// `BonusPoints2 = , `: a list of callsigns in a file in the +/// support-files folder, and what a contact with one of those stations is +/// worth. `+50` adds 50 to the points, `*2` doubles them, and a plain `50` +/// scores 50 instead of them. +/// +/// The file is N1MM's: one callsign per line, followed by a comma and whatever +/// else the line holds. N1MM's other form, where the file is `grids.txt` and +/// the bonus is looked up by grid square, is not read. +public sealed class UdcBonusCalls +{ + private readonly HashSet calls = new(StringComparer.OrdinalIgnoreCase); + private readonly char how; + private readonly int value; + + public UdcBonusCalls(string setting, string? supportFolder) + { + string[] parts = setting.Split(',', StringSplitOptions.TrimEntries); + if (parts.Length < 2 || parts[0].Length == 0 || supportFolder is null) + { + return; + } + string amount = parts[0]; + how = amount[0] is '+' or '*' ? amount[0] : ' '; + if (!int.TryParse(how == ' ' ? amount : amount[1..], out value)) + { + return; + } + foreach (string line in Lines(Path.Combine(supportFolder, parts[1]))) + { + string call = line.Split(',')[0].Trim(); + if (call.Length > 0) + { + calls.Add(call); + } + } + } + + public bool IsDefined => calls.Count > 0; + + /// What the contact scores once the bonus is in. A station that is not on + /// the list scores what it scored. + public int Apply(int points, string call) => + calls.Contains(call.Trim()) switch + { + false => points, + true => how switch + { + '+' => points + value, + '*' => points * value, + _ => value, + }, + }; + + /// A file that is not there leaves the bonus off rather than stopping the + /// contest from opening: the operator can put it in and reload the support + /// files. + private static IReadOnlyList Lines(string path) + { + try + { + return File.ReadAllLines(path); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return []; + } + } +} diff --git a/src/Nonemm.Contests/Udc/UdcMultipliers.cs b/src/Nonemm.Contests/Udc/UdcMultipliers.cs index a94ac30..af852ad 100644 --- a/src/Nonemm.Contests/Udc/UdcMultipliers.cs +++ b/src/Nonemm.Contests/Udc/UdcMultipliers.cs @@ -6,8 +6,8 @@ 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. +/// `CallHist` counts a station the call history file lists, so it needs that +/// file loaded; without one nothing counts. public sealed class UdcMultiplier { private readonly int index; @@ -106,10 +106,16 @@ public sealed class UdcMultiplier "SGRID" => Truncate(qso.Qso.GridSquare, 6), "FIELD" => Truncate(qso.Qso.GridSquare, 2), "CONTINENT" => qso.Continent, + "CALLHIST" => Listed(qso), "FIRSTQSO" => "first", _ => null, }; + /// `CallHist` makes every station in the call history file a multiplier, + /// counted by callsign. N1MM dedups them on the callsign the same way. + private static string? Listed(QsoContext qso) => + qso.History.Find(qso.Qso.Call.Text) is null ? null : qso.Qso.Call.Text.ToUpperInvariant(); + private static string? CountryOnContinent(QsoContext qso, string continent) => continent.Equals(qso.Continent, StringComparison.OrdinalIgnoreCase) ? qso.CountryPrefix : null; diff --git a/src/Nonemm.Contests/Udc/UserDefinedContest.cs b/src/Nonemm.Contests/Udc/UserDefinedContest.cs index e8d4d0b..78a588d 100644 --- a/src/Nonemm.Contests/Udc/UserDefinedContest.cs +++ b/src/Nonemm.Contests/Udc/UserDefinedContest.cs @@ -16,8 +16,12 @@ public sealed class UserDefinedContest : Contest private readonly IReadOnlyList workableStations; private readonly UdcCabrillo cabrillo; private readonly UdcCabrilloFormat cabrilloFormat; + private readonly UdcBonusCalls bonusCalls; - public UserDefinedContest(UdcFile file) + /// `supportFolder` is where `BonusPoints2` reads its list of callsigns + /// from, which is N1MM's support-files folder. Without it that setting is + /// left off. + public UserDefinedContest(UdcFile file, string? supportFolder = null) { this.file = file; points = new UdcPoints(file.Text("PointsPerContact", "1")); @@ -28,6 +32,7 @@ public sealed class UserDefinedContest : Contest workableStations = file.List("IsWorkable"); cabrillo = new UdcCabrillo(file.Text("CabrilloString")); cabrilloFormat = new UdcCabrilloFormat(file.Number("CabrilloFormat", 1)); + bonusCalls = new UdcBonusCalls(file.Text("BonusPoints2"), supportFolder); } public static UserDefinedContest Load(string path) => @@ -97,7 +102,8 @@ public sealed class UserDefinedContest : Contest } string sent = SentExchange(qso); double scored = points.For(qso, sent) * pointsMultiplier.For(qso); - return (int)Math.Round(scored) + bonusPoints.For(qso, sent); + int total = (int)Math.Round(scored) + bonusPoints.For(qso, sent); + return bonusCalls.Apply(total, qso.Qso.Call.Text); } /// What this station sends: the exchange the operator set up for the diff --git a/src/Nonemm.Session/ContestSession.cs b/src/Nonemm.Session/ContestSession.cs index 856f707..373e032 100644 --- a/src/Nonemm.Session/ContestSession.cs +++ b/src/Nonemm.Session/ContestSession.cs @@ -30,7 +30,7 @@ public sealed class ContestSession Contest = contest; Instance = instance; Me = me; - Log = new ContestLog(contest, me, countries, instance.Entry); + Log = new ContestLog(contest, me, countries, instance.Entry) { History = History }; Log.Restore(store.Qsos(instance.ContestNumber)); Editor = new QsoEditor(contest, me, countries); SentNumber = NextSentNumber(); diff --git a/tests/Nonemm.Contests.Tests/UdcScoringTests.cs b/tests/Nonemm.Contests.Tests/UdcScoringTests.cs index d10aa06..49e8546 100644 --- a/tests/Nonemm.Contests.Tests/UdcScoringTests.cs +++ b/tests/Nonemm.Contests.Tests/UdcScoringTests.cs @@ -1,4 +1,5 @@ using Nonemm.Contests.Udc; +using Nonemm.Core.Calls; using Nonemm.Core; namespace Nonemm.Contests.Tests; @@ -291,6 +292,67 @@ public class UdcScoringTests .Judge(TestLog.Contact("OM3XYZ", section: section)) .NewMultipliers.Count); + /// `BonusPoints2` names how much a station on a list is worth and the file + /// the list is in. `+50` adds to what the contact scored. + [Theory] + [InlineData("+50, bonus.txt", 53)] + [InlineData("*2, bonus.txt", 6)] + [InlineData("50, bonus.txt", 50)] + public void BonusPoints2PaysForTheCallsignsInAFile(string setting, int expected) + { + string folder = Directory.CreateTempSubdirectory().FullName; + try + { + File.WriteAllText(Path.Combine(folder, "bonus.txt"), "OM3XYZ,a club station\nOM5ZZZ,\n"); + UserDefinedContest contest = new( + UdcFile.Parse($"[Contest]\nName=BONUS\nPointsPerContact=3\nBonusPoints2={setting}"), + folder); + + Assert.Equal(expected, Points(contest, TestLog.Contact("OM3XYZ"))); + Assert.Equal(3, Points(contest, TestLog.Contact("IK2XYZ"))); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } + + /// A file that is not there leaves the bonus off and the contest opens. + [Fact] + public void BonusPoints2WithoutItsFileScoresTheContactAsItIs() => + Assert.Equal( + 3, + Points( + new UserDefinedContest( + UdcFile.Parse("[Contest]\nName=BONUS\nPointsPerContact=3\nBonusPoints2=+50, missing.txt"), + Path.Combine(Path.GetTempPath(), "nonemm-no-such-folder")), + TestLog.Contact("OM3XYZ"))); + + /// `MultSqlString = CallHist` counts every station the call history file + /// lists, once per callsign. A station the file says nothing about brings + /// no multiplier. + [Fact] + public void CallHistCountsTheStationsTheFileLists() + { + UserDefinedContest contest = Contest("MultSqlString=CallHist", "IsMultPer=4"); + ContestLog log = LogFor(contest); + log.History = CallHistory.Parse("OM3XYZ,JOE\nOM5ZZZ,SAM\n"); + + Assert.Equal( + "OM3XYZ", + log.Judge(TestLog.Contact("OM3XYZ")).NewMultipliers.Single().Value); + Assert.Empty(log.Judge(TestLog.Contact("IK2XYZ")).NewMultipliers); + } + + /// Without a call history file loaded, a `CallHist` multiplier counts + /// nothing rather than counting everybody. + [Fact] + public void CallHistCountsNothingWithoutAFile() => + Assert.Empty( + LogFor(Contest("MultSqlString=CallHist", "IsMultPer=4")) + .Judge(TestLog.Contact("OM3XYZ")) + .NewMultipliers); + /// Published files hold `Country` in this key; the UDC editor puts /// `CountryPrefix` there. [Fact]