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)); } 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; /// 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; set; } /// The other operator's name and a note about the contact. Neither is part /// of any exchange — N1MM keeps both beside the exchange boxes and writes /// them into the log — so neither counts towards a complete exchange. public string OtherName { get; set; } = ""; public string Comment { get; 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; public int SentNumber => Session.SentNumber; public void Tune(Frequency frequency, Mode? mode = null, Frequency? transmitFrequency = null) { Frequency = frequency; Mode = mode ?? Mode; TransmitFrequency = transmitFrequency ?? TransmitFrequency; 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); /// Logs what is typed and clears the entry. Throws when the entry is not /// complete, so a caller that has not checked cannot lose an exchange. public Qso LogContact() { if (!Entry.IsComplete) { throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete"); } Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim()))); Entry.Clear(); OtherName = ""; Comment = ""; return stored; } /// 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 the call history knows 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. Returns true when anything was filled. public bool FillFromHistory() { CallHistoryEntry? known = Session.History.Find(Entry.Call); if (known is null) { return false; } bool filled = false; for (int at = 0; at < Entry.Exchange.Count; at++) { string value = ExchangeSlots.ValueFrom(known, Entry.Exchange[at]); if (value.Length == 0 || Entry[at + 1].Trim().Length > 0) { continue; } Entry[at + 1] = value; filled = true; } if (filled) { Session.NotifyChanged(); } return filled; } /// 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. 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 (string Call, string[] Values, string Name, string Comment)? held; /// 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 ??= (Entry.Call, [.. Enumerable.Range(1, Entry.FieldCount - 1).Select(f => Entry[f])], OtherName, Comment); 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) { Entry.Call = typed.Call; for (int field = 1; field < Entry.FieldCount; field++) { Entry[field] = typed.Values[field - 1]; } OtherName = typed.Name; Comment = typed.Comment; 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. public Qso? SaveQuickEdit() { if (Editing is null) { return null; } 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] = ExchangeSlots.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; } public void Wipe() { Editing = null; held = null; Entry.Clear(); OtherName = ""; Comment = ""; Session.NotifyChanged(); } /// 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(), }; /// 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 qso; } }