using Nonemm.Contests; using Nonemm.Core; using Nonemm.Core.Calls; using Nonemm.Core.Country; using Nonemm.Storage; namespace Nonemm.Session; /// One radio and what the operator is typing on it. A single-radio station has /// one of these; an SO2R station has two, sharing one `ContestSession` and so /// one log, one score and one run of serial numbers. public sealed class OperatingPosition { public OperatingPosition(ContestSession session, int radioNumber = 1) { Session = session; RadioNumber = radioNumber; Entry = new EntryFields(session.Contest.ExchangeFieldsFor(session.Me)); FillSentExchange(); // N1MM fills the sent serial in again on every entry window when a // contact is logged, so the other radio is not left showing the number // that has just gone Session.Logged += (_, _) => FillSentExchange(); Session.NumbersRestarted += (_, _) => LetTheNumberGo(); Entry.CallStarted += (_, _) => ReserveNumber(); } public ContestSession Session { get; } /// 1 or 2. Contacts record which radio made them. public int RadioNumber { get; } public EntryFields Entry { get; } /// With no radio connected this stays where it was last typed. public Frequency Frequency { get; private set; } = Bands.Band20M.Low; /// The band this position is on. A contest that numbers per band takes the /// serial number against it. public Band? Band => Bands.ForFrequency(Frequency); /// Where the radio transmits when working split, and zero when it is not. public Frequency TransmitFrequency { get; private set; } = Frequency.Zero; public Mode Mode { get; private set; } = Modes.Cw; /// True while the operator is calling CQ on this radio rather than /// searching. Each radio runs or searches on its own. public bool IsRunning { get => running; set { if (value && !running) { CqFrequency = Frequency; } running = value; } } private bool running; /// Where this radio last started running or sent a CQ. It stays after the /// radio goes back to searching, so the operator can return to it. public Frequency? CqFrequency { get; set; } /// How far, in hertz, the radio can move off the CQ frequency before the /// position goes back to searching. public long CqLeaveHertz { get; set; } = 3_000; /// The other operator's name and a note about the contact. Neither is part /// of any exchange: the name is what a contact being quick edited already /// held, and the note is what Ctrl+N asks for. Neither counts towards a /// complete exchange. public string OtherName { get; set; } = ""; public string Comment { get; set; } = ""; /// The time this station sent, `HHmm`, for a contest that exchanges it. /// Empty until the exchange has gone out. public string SentTime { get; private set; } = ""; public Contest Contest => Session.Contest; public ContestInstance Instance => Session.Instance; public StationInfo Me => Session.Me; public ContestLog Log => Session.Log; public QsoEditor Editor => Session.Editor; /// The serial number this position sends. It is taken from the pool at the /// first character of the callsign and held until the contact is logged; /// before that it is what the next contact on this band would carry. public int SentNumber => reserved ?? Session.NextNumberOn(Band); private int? reserved; /// Takes this position's number, unless it has one already. N1MM takes it /// at the first character of the callsign rather than at logging, so the /// number is on the network while the contact is still being worked and /// the other operator's next number goes above it. private void ReserveNumber() { if (reserved is not null || !Contest.HasSerialNumbers) { return; } reserved = Session.ReserveNumberOn(Band); FillSentExchange(); Session.NotifyChanged(); } /// Gives up the number this position holds, so the next contact takes a /// fresh one. A wipe keeps it, as N1MM does: the contact is still to come. private void LetTheNumberGo() { reserved = null; FillSentExchange(); } public void Tune(Frequency frequency, Mode? mode = null, Frequency? transmitFrequency = null) { bool modeChanged = mode is not null && mode.Category != Mode.Category; Frequency = frequency; Mode = mode ?? Mode; if (modeChanged) { FillSentExchange(); } TransmitFrequency = transmitFrequency ?? TransmitFrequency; if (running && CqFrequency is { } cq && Math.Abs(frequency.Hertz - cq.Hertz) > CqLeaveHertz) { running = false; } Session.NotifyChanged(); } /// What the log says about what is typed now. Null when there is no call to /// judge yet. public Verdict? Verdict() { string call = Entry.Call.Trim(); return call.Length == 0 ? null : Log.Judge(BuildQso(call)); } /// The same on another band and mode, which is what the band panel asks for /// each of its cells. public Verdict? Verdict(Frequency frequency, Mode mode) { string call = Entry.Call.Trim(); return call.Length == 0 ? null : Log.Judge(BuildQso(call) with { Frequency = frequency, Mode = mode }); } public CountryLookup? Country() => Entry.Call.Trim().Length == 0 ? null : Session.Countries?.Find(Entry.Call.Trim()); /// Every earlier contact with the call being typed, newest first. public IReadOnlyList WorkedBefore() => Entry.Call.Trim().Length == 0 ? [] : Log.WorkedBefore(Entry.Call.Trim()); /// The frequency typed into the callsign box, if that is what it holds. public Frequency? PendingQsy() => FrequencyEntry.Parse(Entry.Call); /// The mode typed into the callsign box, if that is what it holds. public Mode? PendingMode() => ModeEntry.Parse(Entry.Call, Frequency); /// What working this call on the band and mode we are on would bring, /// judged from the call alone. A call that has not been worked has sent no /// exchange, so a contest scored on what the other station sends can be /// judged no further than this. public Verdict? JudgeCall(string call) { string wanted = call.Trim(); return wanted.Length == 0 ? null : Log.Judge(new Qso { Id = "", TimestampUtc = DateTime.UtcNow, Call = Callsign.Parse(wanted), Frequency = Frequency, Mode = Mode, ContestName = Contest.Name, }); } /// The calls answering our CQ that have not been worked yet. public CallStack Stack { get; } = new(); /// N1MM's {STACKANOTHER} and Ctrl+Alt+G: the call being typed goes on the /// stack and the entry is cleared, so the next caller can be copied. Only /// while running, and a dupe is not stacked. False when nothing was /// stacked. public bool StackAnother() { if (!IsRunning || !IsWorthStacking(Entry.Call.Trim())) { return false; } Verdict? worth = Verdict(); if (worth is null || worth.IsDupe) { return false; } Stack.Add(Entry.Call.Trim(), worth.IsNewMultiplier); ClearEntry(); return true; } /// N1MM's Alt+G: the call on top of the stack replaces what is typed. /// False when the stack is empty, and then the entry is left alone. public bool GrabStacked() { if (Stack.IsEmpty) { return false; } string call = Stack.Next(); ClearEntry(); Entry.Call = call; Entry.FocusOn(0); Session.NotifyChanged(); return true; } /// N1MM's {SOCALLSTACK}: one key both stacks and unstacks. With a call /// typed and the stack holding one, the two are swapped — the stacked call /// comes off first, so the typed call cannot come straight back. With the /// box empty it takes the next call off; with the stack empty it puts the /// typed call on. Only while running, and a dupe or a frequency is left /// alone. False when there was nothing to do, which is what N1MM reports /// back to ESM. public bool SwapWithStack() { if (!IsRunning) { return false; } string call = Entry.Call.Trim(); if (call.Length == 0) { return GrabStacked(); } if (!IsWorthStacking(call) || Verdict() is not { IsDupe: false } worth) { return false; } bool isMultiplier = worth.IsNewMultiplier; string next = Stack.Next(); Stack.Add(call, isMultiplier); ClearEntry(); Entry.Call = next; Entry.FocusOn(0); Session.NotifyChanged(); return next.Length > 0; } /// A frequency typed into the callsign box is a band change, not a call, /// and our own callsign is never stacked. N1MM refuses its own call only /// while the stack is empty. private bool IsWorthStacking(string call) => call.Length >= CallStack.ShortestCall && !call.All(c => char.IsAsciiDigit(c) || c == '.') && !string.Equals(call, Me.Callsign, StringComparison.OrdinalIgnoreCase); /// The first entry box holding something that cannot be logged, and why. /// Null when everything typed can go in the log. The log window checks the /// same way, so a value refused here is refused there. public EntryProblem? EntryProblem() => ExchangeCheck.Problem(Entry, Mode); /// Logs what is typed and clears the entry. Throws when the entry is not /// complete or holds a value the contest will not take, so a caller that /// has not checked cannot lose an exchange or log nonsense. public Qso LogContact() { if (!IsEntryComplete) { throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete"); } if (EntryProblem() is { } problem) { throw new InvalidOperationException(problem.Message); } return Store(); } /// Logs what is typed although the exchange is short or a box holds /// something the contest will not take, which is N1MM's `{FORCELOG}`. A /// contact in the log with a hole in it can be filled in afterwards; one /// that was never logged cannot. The callsign is still required, because a /// row with no callsign in it is not a contact. public Qso LogContactAnyway() { if (Entry.Call.Trim().Length == 0) { throw new InvalidOperationException("there is no callsign to log"); } return Store(); } private Qso Store() { StampSentTime(); Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim()))); // the number went with the contact, so the next one starts again from // the pool reserved = null; Entry.Clear(); FillSentExchange(); OtherName = ""; Comment = ""; SentTime = ""; return stored; } /// Notes the time the exchange went out, which the BARTG RTTY contest /// sends and logs. The first message of a contact sets it, so the time in /// the log is the time the other station copied, not the time the contact /// was logged. Does nothing for the contests that do not send a time. public void StampSentTime() { if (Contest.StampsSentTime && SentTime.Length == 0 && Entry.Call.Trim().Length > 2) { SentTime = DateTime.UtcNow.ToString("HHmm"); Session.NotifyChanged(); } } /// Puts what this station sends into the boxes that show it: the report /// and the serial number. N1MM keeps both filled in and steps over them, /// so the number going out is on the screen without being typed. public void FillSentExchange() { foreach (ExchangeField field in Entry.Exchange.Where(f => f.IsSent)) { Entry.Set( field.Slot, field.Slot == ExchangeSlot.SentReport ? DefaultReport() : SentNumber.ToString()); } } /// Puts the report the operator would send anyway into the report boxes /// that are empty: 599 on CW and digital modes, 59 on phone. N1MM does the /// same when the callsign box is left, which is why neither program stops /// on a report box. public bool FillReports() { bool filled = false; for (int at = 0; at < Entry.Exchange.Count; at++) { if (Entry.Exchange[at].Kind == ExchangeFieldKind.Report && Entry[at + 1].Trim().Length == 0) { Entry[at + 1] = DefaultReport(); filled = true; } } if (filled) { Session.NotifyChanged(); } return filled; } /// Fills what is already known about the station into the exchange boxes /// that are still empty. What the other station actually sends wins, so a /// box with something in it is left alone. A zone box takes the call /// history file first, then the zone logged on the last contact with the /// call, then the zone the country file gives for the callsign, which is /// what N1MM does. Returns true when anything was filled. public bool FillFromHistory() { CallHistoryEntry? known = Session.History.Find(Entry.Call); bool filled = false; for (int at = 0; at < Entry.Exchange.Count; at++) { ExchangeField field = Entry.Exchange[at]; string value = known is null ? "" : ExchangeSlots.ValueFrom(known, field); if (value.Length == 0) { value = KnownZone(field); } if (value.Length == 0 || Entry[at + 1].Trim().Length > 0) { continue; } Entry[at + 1] = value; filled = true; } if (filled) { Session.NotifyChanged(); } return filled; } /// The zone to suggest for a zone box: the one on the newest earlier /// contact with this call that has one, else the country file's zone. /// Empty for any other box. private string KnownZone(ExchangeField field) { if (field.Kind is not (ExchangeFieldKind.CqZone or ExchangeFieldKind.ItuZone)) { return ""; } string logged = WorkedBefore() .Select(qso => ExchangeSlots.ValueOf(qso, field.Slot)) .FirstOrDefault(value => value.Length > 0) ?? ""; if (logged.Length > 0) { return logged; } CountryLookup? their = Country(); int zone = their is null ? 0 : field.Kind == ExchangeFieldKind.CqZone ? their.CqZone : their.ItuZone; return zone > 0 ? zone.ToString() : ""; } /// Moves to the next box the operator types in. On top of the report boxes /// the entry itself steps over, a contest can say a box is not for this /// station: CQ WW RTTY asks only the US and Canada for a state. /// True when everything this contest asks for this station has been typed. /// A box the walk steps over is not asked for, so it does not hold the /// contact up: `MoveFocus` and this read the same rule. public bool IsEntryComplete => Entry.IsComplete(Applies); private bool Applies(ExchangeField field) => !Contest.SkipsField(field, Country()); public void MoveFocus(bool forward) { CountryLookup? their = Country(); for (int step = 0; step < Entry.FieldCount; step++) { if (forward) { Entry.Advance(); } else { Entry.Retreat(); } if (Entry.Focus == 0 || !Contest.SkipsField(Entry.Exchange[Entry.Focus - 1], their)) { return; } } } /// The logged contact the boxes are re-editing, or null while a new one is /// being typed. N1MM calls this quick edit: Ctrl+Q steps back through the /// log, Ctrl+A forward, Enter writes the changes back and Esc leaves. public Qso? Editing { get; private set; } private TypedEntry? held; private TypedEntry? wiped; /// Steps one contact back or forward and loads it into the boxes. False /// when the log has nothing that way; stepping forward past the newest /// contact leaves quick edit instead. public bool QuickEdit(bool forward) { IReadOnlyList qsos = Log.Qsos; if (qsos.Count == 0) { return false; } int at = Editing is null ? qsos.Count : IndexOf(Editing.Id); int wanted = forward ? at + 1 : at - 1; if (wanted < 0 || at < 0) { return false; } if (wanted >= qsos.Count) { LeaveQuickEdit(); return true; } held ??= Typed(); Editing = qsos[wanted]; LoadFrom(Editing); Session.NotifyChanged(); return true; } /// Puts the boxes back to the contact that was being typed. public void LeaveQuickEdit() { if (Editing is null) { return; } Editing = null; if (held is { } typed) { PutBack(typed); held = null; } Session.NotifyChanged(); } /// Writes the boxes back to the contact being quick edited and leaves quick /// edit. Null when no contact is being edited. Throws on a value the /// contest will not take, the same as logging a new contact does. public Qso? SaveQuickEdit() { if (Editing is null) { return null; } if (EntryProblem() is { } problem) { throw new InvalidOperationException(problem.Message); } Qso saved = Noted(ApplyExchange(CountryFields.Apply( Editing with { Call = Callsign.Parse(Entry.Call.Trim()) }, Session.Countries))); Session.Update(saved); LeaveQuickEdit(); return saved; } /// The contact as it would be logged now, for the dialog that edits the one /// in progress. public Qso InProgress() => Noted(BuildQso(Entry.Call.Trim())); /// Fills the boxes from a contact, as quick edit does. public void LoadFrom(Qso qso) { Entry.Call = qso.Call.Text; for (int at = 0; at < Entry.Exchange.Count; at++) { Entry[at + 1] = QsoExchange.ValueOf(qso, Entry.Exchange[at].Slot); } OtherName = Entry.Exchange.Any(f => f.Slot == ExchangeSlot.Name) ? "" : qso.Name; Comment = qso.Comment; } private int IndexOf(string id) { for (int at = 0; at < Log.Qsos.Count; at++) { if (Log.Qsos[at].Id == id) { return at; } } return -1; } /// Clears the boxes, keeping what was in them for `RestoreWiped`. An entry /// with nothing typed in it is not worth keeping, so a second wipe does not /// throw the first one away. public void Wipe() { if (!Entry.IsEmpty) { wiped = Typed(); } ClearEntry(); } /// N1MM's Alt+W and its Wipe button: one key both ways. With something in /// the boxes it clears them; with the boxes empty it puts back what the /// last wipe cleared. public void WipeOrRestore() { if (!RestoreWiped()) { Wipe(); } } /// Puts back what the last wipe cleared. False when no wipe /// has anything to put back, or when the boxes have something in them /// again — N1MM restores into an empty entry only, so nothing typed since /// the wipe is lost. public bool RestoreWiped() { if (wiped is not { } typed || !Entry.IsEmpty) { return false; } PutBack(typed); Entry.FocusOn(0); Session.NotifyChanged(); return true; } /// Wipes without keeping anything: the call has gone somewhere else, such /// as onto the call stack, so there is nothing to put back. private void ClearEntry() { Editing = null; held = null; Entry.Clear(); FillSentExchange(); OtherName = ""; Comment = ""; SentTime = ""; Session.NotifyChanged(); } private TypedEntry Typed() => new( Entry.Call, [.. Enumerable.Range(1, Entry.FieldCount - 1).Select(f => Entry[f])], OtherName, Comment, SentTime); private void PutBack(TypedEntry typed) { Entry.Call = typed.Call; // the contest can have changed since, and with it the boxes there are for (int field = 1; field < Entry.FieldCount && field <= typed.Values.Count; field++) { Entry[field] = typed.Values[field - 1]; } OtherName = typed.Name; Comment = typed.Comment; SentTime = typed.SentTime; } /// The name and comment boxes fill the log columns of the same names. A /// contest that exchanges a name has already filled that column, and what /// was exchanged wins. private Qso Noted(Qso qso) => qso with { Name = qso.Name.Length > 0 ? qso.Name : OtherName.Trim(), Comment = qso.Comment.Length > 0 ? qso.Comment : Comment.Trim(), MiscText = qso.MiscText.Length > 0 ? qso.MiscText : SentTime, }; /// The report an operator sends without thinking about it: 599 on CW and /// digital modes, 59 on phone. public string DefaultReport() => Mode.Category switch { ModeCategory.Cw => "599", ModeCategory.Phone => "59", _ => "599", }; private Qso BuildQso(string call) { Qso qso = new() { Id = Qso.NewId(), TimestampUtc = DateTime.UtcNow.AddTicks(-(DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)), Call = Callsign.Parse(call), Frequency = Frequency, QsxFrequency = TransmitFrequency, Mode = Mode, RadioNumber = RadioNumber, ContestName = Contest.Name, ContestNumber = Instance.ContestNumber, SentReport = DefaultReport(), SentNumber = SentNumber, Operator = Session.Operator.Length > 0 ? Session.Operator : Me.Callsign, IsRunQso = IsRunning, }; return ApplyExchange(CountryFields.Apply(qso, Session.Countries)); } private Qso ApplyExchange(Qso qso) { foreach (ExchangeField field in Entry.Exchange) { qso = ExchangeSlots.Apply(qso, field.Slot, Entry.ValueOf(field.Slot).Trim()); } return Entry.Exchange.Any(f => f.Slot == ExchangeSlot.Exchange1) ? Contest.FromExchange1(qso, Entry.ValueOf(ExchangeSlot.Exchange1).Trim()) : qso; } }