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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-31 13:31:25 +00:00
parent 1d3167cd04
commit e3fa979584
10 changed files with 184 additions and 18 deletions

View File

@@ -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 needed a third multiplier and its own points table because it shares a name
with the CW and SSB running of the contest. with the CW and SSB running of the contest.
`.udc` settings that are still passed over. `CallHist` as a multiplier source, `.udc` settings that are still passed over. `MultiplierBands` and
which takes the value from the call history file. `BonusPoints2`, which reads `QsoErrorString`, which are about the windows rather than the score.
the bonus callsigns from a file. `MultiplierBands` and `QsoErrorString`, which `GenericPrintString`, which is the layout of a printed log rather than a
are about the windows rather than the score. `GenericPrintString`, which is the Cabrillo one.
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 `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 multiplier is checked against. Four of N1MM's lists are held here — the ARRL

View File

@@ -49,7 +49,10 @@ public sealed class AppSession : IDisposable
Countries = LoadCountryFile(paths.CountryFile); Countries = LoadCountryFile(paths.CountryFile);
Calls = LoadCallDatabase(paths.CallDatabaseFile); Calls = LoadCallDatabase(paths.CallDatabaseFile);
History = LoadCallHistory(settings.CallHistoryFile); History = LoadCallHistory(settings.CallHistoryFile);
Registry = ContestRegistry.FromFolder(paths.UserDefinedContests, out IReadOnlyList<string> problems); Registry = ContestRegistry.FromFolder(
paths.UserDefinedContests,
out IReadOnlyList<string> problems,
paths.SupportFiles);
UserDefinedContestProblems = problems; UserDefinedContestProblems = problems;
BandPlan = settings.ToBandPlan(); BandPlan = settings.ToBandPlan();
ApplySpotSettings(); ApplySpotSettings();
@@ -520,7 +523,7 @@ public sealed class AppSession : IDisposable
Countries = LoadCountryFile(Paths.CountryFile); Countries = LoadCountryFile(Paths.CountryFile);
Calls = LoadCallDatabase(Paths.CallDatabaseFile); Calls = LoadCallDatabase(Paths.CallDatabaseFile);
History = LoadCallHistory(Settings.CallHistoryFile); History = LoadCallHistory(Settings.CallHistoryFile);
Registry = ContestRegistry.FromFolder(Paths.UserDefinedContests, out _); Registry = ContestRegistry.FromFolder(Paths.UserDefinedContests, out _, Paths.SupportFiles);
ApplySpotSettings(); ApplySpotSettings();
if (Logging is not null) if (Logging is not null)
{ {

View File

@@ -1,4 +1,5 @@
using Nonemm.Core; using Nonemm.Core;
using Nonemm.Core.Calls;
using Nonemm.Core.Country; using Nonemm.Core.Country;
namespace Nonemm.Contests; namespace Nonemm.Contests;
@@ -31,6 +32,10 @@ public sealed class ContestLog
/// change them while the contest is open, and the score follows. /// change them while the contest is open, and the score follows.
public ContestEntry Entry { get; set; } 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 Contest Contest => contest;
public IReadOnlyList<Qso> Qsos => qsos; public IReadOnlyList<Qso> Qsos => qsos;
@@ -132,7 +137,10 @@ public sealed class ContestLog
/// is signing from is looked up when the call as sent places nowhere. What /// is signing from is looked up when the call as sent places nowhere. What
/// that country counts for is the contest's decision. /// that country counts for is the contest's decision.
private QsoContext ContextFor(Qso qso) => 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) => private static Qso ApplyVerdict(Qso qso, Verdict verdict) =>
ApplyMultiplierFlags(qso, verdict.NewMultipliers) with { Points = verdict.Points }; ApplyMultiplierFlags(qso, verdict.NewMultipliers) with { Points = verdict.Points };

View File

@@ -42,19 +42,23 @@ public sealed class ContestRegistry
private ContestRegistry(Dictionary<string, ContestChoice> byName) => this.byName = byName; private ContestRegistry(Dictionary<string, ContestChoice> byName) => this.byName = byName;
public static ContestRegistry Create(IEnumerable<UdcFile>? 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<UdcFile>? userDefined = null,
string? supportFolder = null)
{ {
Dictionary<string, ContestChoice> byName = BuiltIn.ToDictionary( Dictionary<string, ContestChoice> byName = BuiltIn.ToDictionary(
c => c.Name, c => c.Name,
StringComparer.OrdinalIgnoreCase); StringComparer.OrdinalIgnoreCase);
foreach (UdcFile file in userDefined ?? []) foreach (UdcFile file in userDefined ?? [])
{ {
UserDefinedContest contest = new(file); UserDefinedContest contest = new(file, supportFolder);
byName[contest.Name] = new ContestChoice( byName[contest.Name] = new ContestChoice(
contest.Name, contest.Name,
contest.DisplayName, contest.DisplayName,
contest.Modes, contest.Modes,
_ => new UserDefinedContest(file)); _ => new UserDefinedContest(file, supportFolder));
} }
return new ContestRegistry(byName); return new ContestRegistry(byName);
} }
@@ -62,7 +66,10 @@ public sealed class ContestRegistry
/// Reads every `.udc` file in the folder. Published files come with the /// 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 /// extension in either case, and a file that will not parse is reported
/// rather than silently left out. /// rather than silently left out.
public static ContestRegistry FromFolder(string folder, out IReadOnlyList<string> problems) public static ContestRegistry FromFolder(
string folder,
out IReadOnlyList<string> problems,
string? supportFolder = null)
{ {
List<UdcFile> files = []; List<UdcFile> files = [];
List<string> failures = []; List<string> failures = [];
@@ -83,7 +90,7 @@ public sealed class ContestRegistry
} }
} }
problems = failures; problems = failures;
return Create(files); return Create(files, supportFolder);
} }
public IReadOnlyList<ContestChoice> Choices => public IReadOnlyList<ContestChoice> Choices =>

View File

@@ -1,4 +1,5 @@
using Nonemm.Core; using Nonemm.Core;
using Nonemm.Core.Calls;
using Nonemm.Core.Country; using Nonemm.Core.Country;
namespace Nonemm.Contests; namespace Nonemm.Contests;
@@ -7,6 +8,10 @@ namespace Nonemm.Contests;
/// score by country, zone or continent without looking anything up itself. /// score by country, zone or continent without looking anything up itself.
public sealed record QsoContext(Qso Qso, CountryLookup? Country, StationInfo Me, ContestEntry Entry) 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 Band? Band => Qso.Band;
public ModeCategory ModeCategory => Qso.Mode.Category; public ModeCategory ModeCategory => Qso.Mode.Category;

View File

@@ -0,0 +1,70 @@
namespace Nonemm.Contests.Udc;
/// `BonusPoints2 = <value>, <file>`: 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<string> 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<string> Lines(string path)
{
try
{
return File.ReadAllLines(path);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
return [];
}
}
}

View File

@@ -6,8 +6,8 @@ namespace Nonemm.Contests.Udc;
/// One of a user-defined contest's up-to-three multipliers: where its value /// 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. /// 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 /// `CallHist` counts a station the call history file lists, so it needs that
/// needs that file loaded before a multiplier can be worked out. /// file loaded; without one nothing counts.
public sealed class UdcMultiplier public sealed class UdcMultiplier
{ {
private readonly int index; private readonly int index;
@@ -106,10 +106,16 @@ public sealed class UdcMultiplier
"SGRID" => Truncate(qso.Qso.GridSquare, 6), "SGRID" => Truncate(qso.Qso.GridSquare, 6),
"FIELD" => Truncate(qso.Qso.GridSquare, 2), "FIELD" => Truncate(qso.Qso.GridSquare, 2),
"CONTINENT" => qso.Continent, "CONTINENT" => qso.Continent,
"CALLHIST" => Listed(qso),
"FIRSTQSO" => "first", "FIRSTQSO" => "first",
_ => null, _ => 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) => private static string? CountryOnContinent(QsoContext qso, string continent) =>
continent.Equals(qso.Continent, StringComparison.OrdinalIgnoreCase) ? qso.CountryPrefix : null; continent.Equals(qso.Continent, StringComparison.OrdinalIgnoreCase) ? qso.CountryPrefix : null;

View File

@@ -16,8 +16,12 @@ public sealed class UserDefinedContest : Contest
private readonly IReadOnlyList<string> workableStations; private readonly IReadOnlyList<string> workableStations;
private readonly UdcCabrillo cabrillo; private readonly UdcCabrillo cabrillo;
private readonly UdcCabrilloFormat cabrilloFormat; 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; this.file = file;
points = new UdcPoints(file.Text("PointsPerContact", "1")); points = new UdcPoints(file.Text("PointsPerContact", "1"));
@@ -28,6 +32,7 @@ public sealed class UserDefinedContest : Contest
workableStations = file.List("IsWorkable"); workableStations = file.List("IsWorkable");
cabrillo = new UdcCabrillo(file.Text("CabrilloString")); cabrillo = new UdcCabrillo(file.Text("CabrilloString"));
cabrilloFormat = new UdcCabrilloFormat(file.Number("CabrilloFormat", 1)); cabrilloFormat = new UdcCabrilloFormat(file.Number("CabrilloFormat", 1));
bonusCalls = new UdcBonusCalls(file.Text("BonusPoints2"), supportFolder);
} }
public static UserDefinedContest Load(string path) => public static UserDefinedContest Load(string path) =>
@@ -97,7 +102,8 @@ public sealed class UserDefinedContest : Contest
} }
string sent = SentExchange(qso); string sent = SentExchange(qso);
double scored = points.For(qso, sent) * pointsMultiplier.For(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 /// What this station sends: the exchange the operator set up for the

View File

@@ -30,7 +30,7 @@ public sealed class ContestSession
Contest = contest; Contest = contest;
Instance = instance; Instance = instance;
Me = me; 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)); Log.Restore(store.Qsos(instance.ContestNumber));
Editor = new QsoEditor(contest, me, countries); Editor = new QsoEditor(contest, me, countries);
SentNumber = NextSentNumber(); SentNumber = NextSentNumber();

View File

@@ -1,4 +1,5 @@
using Nonemm.Contests.Udc; using Nonemm.Contests.Udc;
using Nonemm.Core.Calls;
using Nonemm.Core; using Nonemm.Core;
namespace Nonemm.Contests.Tests; namespace Nonemm.Contests.Tests;
@@ -291,6 +292,67 @@ public class UdcScoringTests
.Judge(TestLog.Contact("OM3XYZ", section: section)) .Judge(TestLog.Contact("OM3XYZ", section: section))
.NewMultipliers.Count); .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 /// Published files hold `Country` in this key; the UDC editor puts
/// `CountryPrefix` there. /// `CountryPrefix` there.
[Fact] [Fact]