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]