CabrilloFormat 2, 3, 4 and 5 name the NAQP, NA Sprint, Sweepstakes and section-and-serial lines. A file asking for one of those got the default line; it now gets the layout, ported from CabrilloString2 and CabrilloString4 in N1MM's Contact.cs, in N1MM's columns. The exchange the layouts split into columns is built from the boxes the file defines, in the order it defines them. N1MM builds it from the section box alone, which writes only half of a two-box exchange such as NAQP's name and state. Format 6, the ARRL RTTY Roundup line, is still not read. No published .udc file asks for it, because the Roundup has a contest class of its own. ExchangeSlots.ValueOf moves to Nonemm.Contests as QsoExchange.ValueOf, so the contest code can read a contact through its exchange boxes without a second copy of that switch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
259 lines
11 KiB
C#
259 lines
11 KiB
C#
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<UdcMultiplier> multipliers;
|
|
private readonly IReadOnlyList<ExchangeField> exchangeFields;
|
|
private readonly IReadOnlyList<string> 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<ExchangeField> ExchangeFieldsFor(StationInfo me) => exchangeFields;
|
|
|
|
public IReadOnlyList<string> 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<ModeCategory> 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<Multiplier> MultipliersFor(QsoContext qso) =>
|
|
IsWorkable(qso)
|
|
? multipliers.Select(m => m.For(qso)).OfType<Multiplier>().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<UdcMultiplier> ReadMultipliers(UdcFile file)
|
|
{
|
|
UdcMultiplierScope shared = (UdcMultiplierScope)file.Number("IsMultPer", 0);
|
|
List<UdcMultiplier> 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<ExchangeField> ReadExchangeFields(UdcFile file)
|
|
{
|
|
string[] labels = file.Text("FrameText").Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
List<ExchangeField> 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,
|
|
};
|
|
}
|