Act on the .udc session, off-time and band-change settings

Four settings were read by nothing.

MultipleSessions = 0000/30 runs the contest in half-hour sessions from
00:00 UTC. The session is part of the dupe key, so a station worked in
an earlier one may be worked again, and ResetMultsEverySession and
ResetSNEverySession start the multipliers and the serial numbers over.

DupeQSOMinutesAgo is the other way back to a station: after so many
minutes rather than in the next session. The log holds the time of the
last contact per dupe key instead of only the key, and the setting's
IgnoreBand and ThisMode decide the scope, because a file that uses it
turns DupeType off.

MinimumOffTime drives the time on and time off the score summary now
shows under the score, and beside them are the band changes made against
the allowance for a contest that limits them. N1MM's countdown for the
minutes you have to stay on a new band needs its info window and is not
here.

UdcFile.Flag now takes 1 and 0 as well as True and False, which is what
these settings are written as and what N1MM reads.

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:32:01 +00:00
co-authored by Claude Opus 5
parent e3fa979584
commit 6c2ee0f78f
15 changed files with 551 additions and 28 deletions
+48
View File
@@ -0,0 +1,48 @@
namespace Nonemm.Contests.Udc;
/// `DupeQSOMinutesAgo = 10, True, False, True, False`: a station worked less
/// than ten minutes ago is a dupe, and after that it can be worked again. The
/// settings after the minutes are N1MM's, and two of them decide how wide to
/// look for that earlier contact:
///
/// | Setting | Default | What it does here |
/// |---|---|---|
/// | CompareToSecond | True | not read: the times are compared to the second |
/// | IgnoreBand | False | true looks on every band rather than this one |
/// | InfoStatus | True | not read: it turns N1MM's info window message on |
/// | ThisMode | False | true looks only at contacts in the same mode |
///
/// A file that sets this also sets `DupeType = 4`, which is N1MM's way of
/// saying the time decides on its own, so the scope here comes from these two
/// settings rather than from `DupeType`.
public sealed class UdcDupeWindow
{
private readonly int minutes;
private readonly bool ignoresBand;
private readonly bool thisModeOnly;
public UdcDupeWindow(string setting)
{
string[] parts = setting.Split(',', StringSplitOptions.TrimEntries);
if (parts.Length == 0 || !int.TryParse(parts[0], out minutes))
{
return;
}
ignoresBand = Flag(parts, 2, false);
thisModeOnly = Flag(parts, 4, false);
}
public bool IsDefined => minutes > 0;
public TimeSpan? Window => IsDefined ? TimeSpan.FromMinutes(minutes) : null;
public DupeScope Scope =>
ignoresBand
? thisModeOnly ? DupeScope.PerMode : DupeScope.Once
: thisModeOnly ? DupeScope.PerBandAndMode : DupeScope.PerBand;
private static bool Flag(string[] parts, int at, bool absent) =>
parts.Length > at && parts[at].Length > 0
? parts[at].Equals("TRUE", StringComparison.OrdinalIgnoreCase)
: absent;
}
+11 -2
View File
@@ -55,8 +55,17 @@ public sealed class UdcFile
? value
: fallback;
public bool Flag(string key, bool fallback) =>
bool.TryParse(Lookup(key), out bool value) ? value : fallback;
/// A yes-or-no setting. Files write these as `True`/`False` and as `1`/`0`,
/// and N1MM takes both.
public bool Flag(string key, bool fallback)
{
string? value = Lookup(key)?.Trim();
if (bool.TryParse(value, out bool said))
{
return said;
}
return int.TryParse(value, out int number) ? number != 0 : fallback;
}
/// A comma-separated value with the spaces trimmed off each part.
public IReadOnlyList<string> List(string key) =>
+66
View File
@@ -0,0 +1,66 @@
using System.Globalization;
namespace Nonemm.Contests.Udc;
/// `MultipleSessions = 0000/30`: the contest runs in sessions, the first one
/// starting at 00:00 UTC and each lasting 30 minutes. A station worked in an
/// earlier session may be worked again, and `ResetMultsEverySession` and
/// `ResetSNEverySession` say whether the multipliers and the serial numbers
/// start over as well.
///
/// N1MM writes the length as minutes up to two digits and as hours and minutes
/// above that: `30` is half an hour, `200` is two hours. The shortest session
/// N1MM takes is ten minutes.
public sealed class UdcSessions
{
private static readonly TimeSpan Shortest = TimeSpan.FromMinutes(10);
private readonly TimeSpan start;
private readonly TimeSpan length;
public UdcSessions(UdcFile file)
{
ResetsMultipliers = file.Flag("ResetMultsEverySession", false);
ResetsSerialNumbers = file.Flag("ResetSNEverySession", false);
string[] parts = file.Text("MultipleSessions").Split('/', StringSplitOptions.TrimEntries);
if (parts.Length != 2)
{
return;
}
TimeSpan? first = HoursAndMinutes(parts[0]);
TimeSpan? each = HoursAndMinutes(parts[1]);
if (first is null || each is null || each < Shortest)
{
return;
}
start = first.Value;
length = each.Value;
IsDefined = true;
}
public bool IsDefined { get; }
public bool ResetsMultipliers { get; }
public bool ResetsSerialNumbers { get; }
/// Which session a contact belongs to. The sessions are counted off a
/// fixed date so that two days of a contest never share a number, which is
/// what N1MM's own stepping from the start time gives. Every contact is in
/// session 0 for a contest without sessions.
public int SessionOf(DateTime utc) =>
IsDefined ? (int)((utc - (DateTime.UnixEpoch + start)).Ticks / length.Ticks) : 0;
/// `30` is thirty minutes, `200` two hours, `0000` midnight. A value that
/// is not a number at all leaves the setting off.
private static TimeSpan? HoursAndMinutes(string value)
{
if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int number))
{
return null;
}
return value.Length > 2
? TimeSpan.FromMinutes((number / 100 * 60) + (number % 100))
: TimeSpan.FromMinutes(number);
}
}
+43 -6
View File
@@ -17,6 +17,8 @@ public sealed class UserDefinedContest : Contest
private readonly UdcCabrillo cabrillo;
private readonly UdcCabrilloFormat cabrilloFormat;
private readonly UdcBonusCalls bonusCalls;
private readonly UdcSessions sessions;
private readonly UdcDupeWindow dupeWindow;
/// `supportFolder` is where `BonusPoints2` reads its list of callsigns
/// from, which is N1MM's support-files folder. Without it that setting is
@@ -33,6 +35,8 @@ public sealed class UserDefinedContest : Contest
cabrillo = new UdcCabrillo(file.Text("CabrilloString"));
cabrilloFormat = new UdcCabrilloFormat(file.Number("CabrilloFormat", 1));
bonusCalls = new UdcBonusCalls(file.Text("BonusPoints2"), supportFolder);
sessions = new UdcSessions(file);
dupeWindow = new UdcDupeWindow(file.Text("DupeQSOMinutesAgo"));
}
public static UserDefinedContest Load(string path) =>
@@ -55,13 +59,46 @@ public sealed class UserDefinedContest : Contest
.Where(name => name.Length > 0)
.ToList();
public DupeScope DupeScope => file.Number("DupeType", 2) switch
/// `DupeQSOMinutesAgo` decides the scope itself when it is set, because a
/// file that uses it turns `DupeType` off.
public DupeScope DupeScope => dupeWindow.IsDefined
? dupeWindow.Scope
: file.Number("DupeType", 2) switch
{
1 => DupeScope.Once,
3 => DupeScope.PerBandAndMode,
4 => DupeScope.Never,
_ => DupeScope.PerBand,
};
public TimeSpan? DupeWindow => dupeWindow.Window;
public int SessionOf(DateTime utc) => sessions.SessionOf(utc);
public bool ResetsMultipliersEachSession => sessions.ResetsMultipliers;
public bool ResetsSerialNumbersEachSession => sessions.ResetsSerialNumbers;
public TimeSpan MinimumOffTime => TimeSpan.FromMinutes(file.Number("MinimumOffTime", 30));
/// The `…BandChange…` family. A multi-op entry reads the `MultiOp…`
/// settings and everyone else the `SingleOp…` ones, which is the split
/// N1MM makes.
public BandChangeRules BandChangesFor(ContestEntry entry)
{
1 => DupeScope.Once,
3 => DupeScope.PerBandAndMode,
4 => DupeScope.Never,
_ => DupeScope.PerBand,
};
bool multiOp = entry.OperatorCategory.StartsWith("MULTI", StringComparison.OrdinalIgnoreCase);
string side = multiOp ? "MultiOp" : "SingleOp";
string max = multiOp ? "MOBandChangeCountMax" : "SOBandChangeCountMax";
string stay = multiOp ? "MOBandChangeTimerDuration" : "SOBandChangeTimerDuration";
return file.Flag($"{side}CountableBandChange", false)
? new BandChangeRules(
file.Number(max, 0),
file.Flag("CountBandChangesPerContest", false),
file.Flag("CountBandChangesPerPeriod", false),
file.Flag("CountBandOrModeChange", false),
TimeSpan.FromMinutes(file.Number(stay, 0)))
: BandChangeRules.None;
}
/// `DupeSqlString` names one more field that makes a contact distinct: a
/// station worked again with a different section, exchange, mode or grid