Files
Nonemm/src/Nonemm.Contests/ContestRegistry.cs
T
erikbrockoandClaude Opus 5 e3fa979584 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
2026-08-31 13:31:25 +00:00

129 lines
6.2 KiB
C#

using Nonemm.Contests.Rules;
using Nonemm.Contests.Udc;
using Nonemm.Core;
namespace Nonemm.Contests;
/// The contests on offer: the ones written into the program, plus whatever
/// `.udc` files the operator has put in the user-defined contests folder.
public sealed class ContestRegistry
{
private static readonly IReadOnlyList<ContestChoice> BuiltIn =
[
new("CQWW", "CQ World Wide DX", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new CqWorldWide(m)),
new("CQWPX", "CQ WPX", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new CqWpx(m)),
new("ARRLDX", "ARRL International DX", [ModeCategory.Cw, ModeCategory.Phone], m => new ArrlDx(m)),
new("IARU", "IARU HF World Championship", [ModeCategory.Cw, ModeCategory.Phone], _ => new IaruHf()),
new("SS", "ARRL Sweepstakes", [ModeCategory.Cw, ModeCategory.Phone], m => new Sweepstakes(m)),
new("WAE", "Worked All Europe DX", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new Wae(m)),
new("ARRLRTTY", "ARRL RTTY Roundup", [ModeCategory.Digital], _ => new RttyRoundup()),
new("NAQP", "North American QSO Party", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new NorthAmericanQsoParty(m)),
new("OKOMDX", "Czech and Slovak Republics DX", [ModeCategory.Cw], _ => new OkOmDx()),
new("YOTA", "YOTA Contest", [ModeCategory.Cw, ModeCategory.Phone], _ => new Yota()),
new("IOTA", "RSGB Islands On The Air", [ModeCategory.Cw, ModeCategory.Phone], _ => new Iota()),
new("EUDXC", "EU DX Contest", [ModeCategory.Cw, ModeCategory.Phone], _ => new EuDxContest()),
new("CQ160", "CQ World-Wide 160 Metre", [ModeCategory.Cw, ModeCategory.Phone], m => new CqOneSixty(m)),
new("XERTTY", "Mexico RTTY Contest", [ModeCategory.Digital], _ => new MexicoRtty()),
new("REF", "French REF DX contest", [ModeCategory.Cw, ModeCategory.Phone], m => new FrenchRef(m)),
new("UKRAINDX", "Ukrainian DX", [ModeCategory.Cw, ModeCategory.Phone], _ => new UkrainianDx()),
new("BARTGRTTYS", "BARTG RTTY Sprint", [ModeCategory.Digital], _ => new BartgSprint()),
new("ARRL10M", "ARRL 10 Metre Contest", [ModeCategory.Cw, ModeCategory.Phone], _ => new Arrl10Metre()),
new("RUSDXRTTY", "Russian DX RTTY", [ModeCategory.Digital], _ => new RussianDxRtty()),
new("RDAC", "Russian District Award Contest", [ModeCategory.Cw, ModeCategory.Phone], _ => new RussianDistrictAward()),
new("YOHFDX", "YO DX HF", [ModeCategory.Cw, ModeCategory.Phone], _ => new YoDxHf()),
new("OCEANIA", "Oceania DX", [ModeCategory.Cw, ModeCategory.Phone], m => new OceaniaDx(m)),
new("FDREG1", "IARU Region 1 Field Day", [ModeCategory.Cw, ModeCategory.Phone], _ => new FieldDayRegionOne()),
new("SARTGRTTY", "SARTG World Wide RTTY", [ModeCategory.Digital], _ => new SartgRtty()),
new("ALLASIA", "All Asian DX", [ModeCategory.Cw, ModeCategory.Phone], m => new AllAsian(m)),
new("DX", "General logging", [], _ => new GeneralLogging()),
];
private readonly Dictionary<string, ContestChoice> byName;
private ContestRegistry(Dictionary<string, ContestChoice> byName) => this.byName = byName;
/// `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(
c => c.Name,
StringComparer.OrdinalIgnoreCase);
foreach (UdcFile file in userDefined ?? [])
{
UserDefinedContest contest = new(file, supportFolder);
byName[contest.Name] = new ContestChoice(
contest.Name,
contest.DisplayName,
contest.Modes,
_ => new UserDefinedContest(file, supportFolder));
}
return new ContestRegistry(byName);
}
/// 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<string> problems,
string? supportFolder = null)
{
List<UdcFile> files = [];
List<string> failures = [];
if (Directory.Exists(folder))
{
IEnumerable<string> found = Directory.EnumerateFiles(folder)
.Where(f => Path.GetExtension(f).Equals(".udc", StringComparison.OrdinalIgnoreCase));
foreach (string path in found)
{
try
{
files.Add(UdcFile.Parse(File.ReadAllText(path)));
}
catch (Exception e) when (e is FormatException or IOException)
{
failures.Add($"{path}: {e.Message}");
}
}
}
problems = failures;
return Create(files, supportFolder);
}
public IReadOnlyList<ContestChoice> Choices =>
byName.Values.OrderBy(c => c.DisplayName, StringComparer.Ordinal).ToList();
public Contest Create(string name, ModeCategory mode)
{
ContestChoice choice = Find(name, ref mode)
?? throw new KeyNotFoundException($"no contest named '{name}'");
return choice.Create(mode);
}
public bool Has(string name)
{
ModeCategory ignored = ModeCategory.Cw;
return Find(name, ref ignored) is not null;
}
/// A name N1MM wrote carries the mode, and that is better than whatever the
/// log's mode column says, so it wins when the name resolves that way.
private ContestChoice? Find(string name, ref ModeCategory mode)
{
if (byName.TryGetValue(name, out ContestChoice? choice))
{
return choice;
}
if (N1mmContestNames.TryResolve(name, out string family, out ModeCategory named)
&& byName.TryGetValue(family, out ContestChoice? resolved))
{
mode = named;
return resolved;
}
return null;
}
}