From 98d51ab7f563846fa3f74d50c3a6fe9629d03ebf Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sun, 30 Aug 2026 22:40:33 +0000 Subject: [PATCH] Take a contact from another station without reopening the contest AppSession.TakeFromNetwork wrote the contact to the store and then called OpenContest to see it, which built a new ContestSession, read the whole log back and built new entry positions. Anything the operator was typing at either radio went with them, and the entry boxes were rebuilt under the other operator's hands. It also ran on the socket thread, so it was changing the log while the windows were reading it. ContestSession now applies the three messages itself: AddFromNetwork, ReplaceFromNetwork and DeleteFromNetwork write the store, change the log in place and raise Changed. None of them raises Logged, Edited or Deleted, so nothing goes back out to the other stations. Points and multipliers are still worked out here from the rules rather than trusted. A contact that arrives goes in where its timestamp says it belongs, through the new ContestLog.Insert, and the log is scored again: of two stations that worked the same multiplier, the one that worked it first keeps it, however late the message turns up. Add would have appended it at the end and given the multiplier to the wrong contact. The socket thread now hands the update to the thread the windows run on, which is where every other change to the log is made. The matching rule moved with the code, so ContestSession answers what a message from N1MM refers to: the contact id, then the call and time it had before the edit. Still not tested against a second station. Two of these cannot run on one host to try it, because they share one UDP port. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD --- README.md | 6 + docs/unfinished.md | 5 - src/Nonemm.App/AppSession.cs | 48 ++--- src/Nonemm.Contests/ContestLog.cs | 9 + src/Nonemm.Session/ContestSession.cs | 58 +++++ .../NetworkedContactsTests.cs | 202 ++++++++++++++++++ 6 files changed, 288 insertions(+), 40 deletions(-) create mode 100644 tests/Nonemm.Session.Tests/NetworkedContactsTests.cs diff --git a/README.md b/README.md index 9d8726a..7e2ee95 100644 --- a/README.md +++ b/README.md @@ -657,6 +657,12 @@ An incoming edit or delete is matched by contact id first. N1MM does not know our ids, so the call and the timestamp are the fallback — that is the pair N1MM itself keys a contact on. +A contact that arrives is put into the log where its timestamp says it belongs +and the log is scored again, so of two stations that worked the same multiplier +the one that worked it first keeps it, however late the message turns up. +Nothing else moves: what is being typed at either radio is left alone, and the +windows redraw where they stand. + ## Layout | Project | What it holds | diff --git a/docs/unfinished.md b/docs/unfinished.md index e5dd250..e5de75b 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -114,11 +114,6 @@ beyond country, prefix, zone, section, exchange, grid and continent. ## Known rough edges -**Contacts arriving over the station network** are applied from the socket -thread, and `AppSession.TakeFromNetwork` reopens the whole contest to do it. -That is correct but heavy, and it discards what is typed in an entry window on -another radio. - **The band plan covers 160M to 2M only.** 60M and everything above 2M get no CW, digital or phone shading on the bandmap, because N1MM's own numbers for those bands contradict themselves — its CW top for 1.25M is below its phone diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 4a14aea..1ff9254 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -1,3 +1,4 @@ +using Avalonia.Threading; using Nonemm.App.Configuration; using Nonemm.Contests; using Nonemm.Core; @@ -225,60 +226,37 @@ public sealed class AppSession : IDisposable Settings.NetworkPort, Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName, Settings.NetworkPeers); - network.UpdateArrived += (_, update) => TakeFromNetwork(update); + // the socket thread must not touch the log: every other change to it is + // made where the windows read it + network.UpdateArrived += (_, update) => + Dispatcher.UIThread.Post(() => TakeFromNetwork(update)); network.Start(); Changed?.Invoke(this, EventArgs.Empty); } - /// What another station did to its log, applied to ours. The store is - /// written directly rather than through `Logging`, so the change is not - /// broadcast back out again. Points and multipliers are worked out here - /// from the rules instead of trusting what the sender put in the message. + /// What another station did to its log, applied to ours. Nothing goes back + /// out to the network, and the contact is scored here from the rules + /// instead of trusting what the sender put in the message. private void TakeFromNetwork(ContactUpdate update) { - if (Logging is null || store is null) + if (Logging is null) { return; } - int contestNumber = Logging.Instance.ContestNumber; switch (update) { - case ContactLogged logged when !Logging.Log.Qsos.Any(q => q.Id == logged.Qso.Id): - store.Add(logged.Qso with { ContestNumber = contestNumber, IsOriginal = false }); + case ContactLogged logged: + Logging.AddFromNetwork(logged.Qso); break; case ContactReplaced replaced: - if (FindLocal(replaced.Qso.Id, replaced.OldCall, replaced.OldTimestampUtc) is not { } old) - { - return; - } - store.Update(replaced.Qso with - { - Id = old.Id, - ContestNumber = contestNumber, - IsOriginal = false, - }); + Logging.ReplaceFromNetwork(replaced.Qso, replaced.OldCall, replaced.OldTimestampUtc); break; case ContactDeleted deleted: - if (FindLocal(deleted.Id, deleted.Call, deleted.TimestampUtc) is not { } gone) - { - return; - } - store.Delete(gone.Id); + Logging.DeleteFromNetwork(deleted.Id, deleted.Call, deleted.TimestampUtc); break; - default: - return; } - OpenContest(contestNumber); } - /// N1MM keys a contact on its call and time, so a message from N1MM carries - /// no id we would recognise. Fall back to that pair when the id misses. - private Qso? FindLocal(string id, string call, DateTime timestampUtc) => - Logging?.Log.Qsos.FirstOrDefault(q => id.Length > 0 && q.Id == id) - ?? Logging?.Log.Qsos.FirstOrDefault(q => - string.Equals(q.Call.Text, call, StringComparison.OrdinalIgnoreCase) - && q.TimestampUtc == timestampUtc); - /// Starts, restarts or stops the keyer, following what the settings say. /// A keyer that will not open is reported; the program keeps running /// without one. diff --git a/src/Nonemm.Contests/ContestLog.cs b/src/Nonemm.Contests/ContestLog.cs index b02718f..ee85164 100644 --- a/src/Nonemm.Contests/ContestLog.cs +++ b/src/Nonemm.Contests/ContestLog.cs @@ -71,6 +71,15 @@ public sealed class ContestLog Rebuild(); } + /// Adds a contact that may be older than ones already logged, which is what + /// a contact from another station of the entry can be, and scores the log + /// again: the earlier of two contacts is the one that claims a multiplier. + public void Insert(Qso qso) + { + qsos.Add(qso); + Rebuild(); + } + public void Remove(string id) { qsos.RemoveAll(q => q.Id == id); diff --git a/src/Nonemm.Session/ContestSession.cs b/src/Nonemm.Session/ContestSession.cs index e4e20b7..ef123a0 100644 --- a/src/Nonemm.Session/ContestSession.cs +++ b/src/Nonemm.Session/ContestSession.cs @@ -107,6 +107,64 @@ public sealed class ContestSession public void NotifyChanged() => Changed?.Invoke(this, EventArgs.Empty); + /// A contact worked by another station of a multi-operator entry. It is + /// scored here from the rules rather than trusting what the sender worked + /// out, and it raises no `Logged`, so it does not go back out to the other + /// stations. False when we already hold it. + public bool AddFromNetwork(Qso qso) + { + if (Log.Qsos.Any(q => q.Id == qso.Id)) + { + return false; + } + Log.Insert(store.Add(FromNetwork(qso))); + SentNumber = NextSentNumber(); + Changed?.Invoke(this, EventArgs.Empty); + return true; + } + + /// An edit made at another station. False when we do not hold the contact + /// it changes. + public bool ReplaceFromNetwork(Qso qso, string oldCall, DateTime oldTimestampUtc) + { + if (Find(qso.Id, oldCall, oldTimestampUtc) is not { } old) + { + return false; + } + Qso replacement = FromNetwork(qso) with { Id = old.Id }; + store.Update(replacement); + Log.Replace(replacement); + Changed?.Invoke(this, EventArgs.Empty); + return true; + } + + /// A contact deleted at another station. False when we do not hold it. + public bool DeleteFromNetwork(string id, string call, DateTime timestampUtc) + { + if (Find(id, call, timestampUtc) is not { } gone) + { + return false; + } + store.Delete(gone.Id); + Log.Remove(gone.Id); + Changed?.Invoke(this, EventArgs.Empty); + return true; + } + + private Qso FromNetwork(Qso qso) => qso with + { + ContestNumber = Instance.ContestNumber, + IsOriginal = false, + }; + + /// N1MM keys a contact on its call and time, so a message from N1MM carries + /// no id we would recognise. Fall back to that pair when the id misses. + private Qso? Find(string id, string call, DateTime timestampUtc) => + Log.Qsos.FirstOrDefault(q => id.Length > 0 && q.Id == id) + ?? Log.Qsos.FirstOrDefault(q => + string.Equals(q.Call.Text, call, StringComparison.OrdinalIgnoreCase) + && q.TimestampUtc == timestampUtc); + private QsoEdit EditWith(string id, Func change) { Qso before = Log.Qsos.FirstOrDefault(q => q.Id == id) diff --git a/tests/Nonemm.Session.Tests/NetworkedContactsTests.cs b/tests/Nonemm.Session.Tests/NetworkedContactsTests.cs new file mode 100644 index 0000000..31eb4fa --- /dev/null +++ b/tests/Nonemm.Session.Tests/NetworkedContactsTests.cs @@ -0,0 +1,202 @@ +using Nonemm.Contests; +using Nonemm.Contests.Rules; +using Nonemm.Core; +using Nonemm.Core.Country; +using Nonemm.Storage; + +namespace Nonemm.Session.Tests; + +/// What a contact from another station of a multi-operator entry does to this +/// log. +public class NetworkedContactsTests +{ + private const string Countries = """ + Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL: + DL,DK,DJ; + Japan: 25: 45: AS: 36.40: -138.38: -9.0: JA: + JA,JH,JR; + """; + + private static readonly StationInfo Me = new() + { + Callsign = "DL1ABC", + CqZone = 14, + ItuZone = 28, + Continent = "EU", + CountryPrefix = "DL", + }; + + private static readonly DateTime Start = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + private static (ContestSession Session, FakeLogStore Store) Station() + { + FakeLogStore store = new(); + ContestInstance instance = store.AddContest(new ContestInstance + { + ContestNumber = 0, + ContestName = "CQWW", + }); + return ( + new ContestSession( + store, + new CqWorldWide(ModeCategory.Cw), + instance, + Me, + CountryFile.Parse(Countries)), + store); + } + + private static Qso Arriving(string call, int minute, int zone = 25) => new() + { + Id = $"{call}-{minute}", + TimestampUtc = Start.AddMinutes(minute), + Call = Callsign.Parse(call), + Frequency = Frequency.FromKilohertz(14_030), + Mode = Modes.Cw, + ContestName = "CQWW", + SentReport = "599", + ReceivedReport = "599", + Zone = zone, + // what the sending station worked out, which is not trusted + Points = 99, + IsMultiplier1 = true, + }; + + /// A contact worked at this station, which `Add` scores from the rules like + /// any other. + private static Qso WorkHere(ContestSession session, string call, int minute, int zone = 25) => + session.Add(Arriving(call, minute, zone) with + { + Id = Qso.NewId(), + ContestNumber = session.Instance.ContestNumber, + }); + + [Fact] + public void AContactFromAnotherStationIsLoggedAndScoredHere() + { + (ContestSession session, _) = Station(); + + Assert.True(session.AddFromNetwork(Arriving("JA1XYZ", 1))); + + Qso stored = Assert.Single(session.Log.Qsos); + Assert.Equal("JA1XYZ", stored.Call.Text); + Assert.Equal(3, stored.Points); + Assert.False(stored.IsOriginal); + Assert.Equal(session.Instance.ContestNumber, stored.ContestNumber); + } + + [Fact] + public void ItDoesNotGoBackOutToTheOtherStations() + { + (ContestSession session, _) = Station(); + int sent = 0; + session.Logged += (_, _) => sent++; + + session.AddFromNetwork(Arriving("JA1XYZ", 1)); + + Assert.Equal(0, sent); + } + + [Fact] + public void TheSameContactArrivingTwiceIsTakenOnce() + { + (ContestSession session, _) = Station(); + session.AddFromNetwork(Arriving("JA1XYZ", 1)); + + Assert.False(session.AddFromNetwork(Arriving("JA1XYZ", 1))); + Assert.Single(session.Log.Qsos); + } + + /// The station that worked it first keeps the multiplier, however late the + /// message about it turns up. + [Fact] + public void AContactThatArrivesLateStillTakesTheMultiplierItWorkedFirst() + { + (ContestSession session, _) = Station(); + WorkHere(session, "JA9ZZZ", minute: 5); + + session.AddFromNetwork(Arriving("JA1XYZ", minute: 1)); + + Assert.True(session.Log.Qsos[0].IsMultiplier1); + Assert.False(session.Log.Qsos[1].IsMultiplier1); + Assert.Equal("JA1XYZ", session.Log.Qsos[0].Call.Text); + } + + [Fact] + public void WhatIsBeingTypedIsLeftAlone() + { + (ContestSession session, _) = Station(); + OperatingPosition position = new(session); + position.Entry.Call = "G4AB"; + position.Entry.Set(ExchangeSlot.Zone, "14"); + + session.AddFromNetwork(Arriving("JA1XYZ", 1)); + + Assert.Equal("G4AB", position.Entry.Call); + Assert.Equal("14", position.Entry.ValueOf(ExchangeSlot.Zone)); + } + + [Fact] + public void AnEditFromAnotherStationIsAppliedById() + { + (ContestSession session, _) = Station(); + session.AddFromNetwork(Arriving("JA1XYZ", 1)); + + Assert.True(session.ReplaceFromNetwork( + Arriving("JA1XYZ", 1) with { Zone = 24 }, + "JA1XYZ", + Start.AddMinutes(1))); + + Assert.Equal(24, Assert.Single(session.Log.Qsos).Zone); + } + + /// N1MM does not know our ids, so a message from N1MM is matched on the + /// call and time the contact had before the edit. + [Fact] + public void AnEditWithNoIdIsMatchedOnTheOldCallAndTime() + { + (ContestSession session, _) = Station(); + Qso worked = WorkHere(session, "JA1XYZ", minute: 1); + + Assert.True(session.ReplaceFromNetwork( + Arriving("JA1XYY", 1) with { Id = "" }, + "JA1XYZ", + worked.TimestampUtc)); + + Qso changed = Assert.Single(session.Log.Qsos); + Assert.Equal("JA1XYY", changed.Call.Text); + Assert.Equal(worked.Id, changed.Id); + } + + [Fact] + public void AnEditToAContactWeDoNotHoldIsRefused() + { + (ContestSession session, _) = Station(); + + Assert.False(session.ReplaceFromNetwork(Arriving("JA1XYZ", 1), "JA1XYZ", Start)); + Assert.Empty(session.Log.Qsos); + } + + [Fact] + public void ADeleteFromAnotherStationRemovesTheContactAndRescores() + { + (ContestSession session, FakeLogStore store) = Station(); + session.AddFromNetwork(Arriving("JA1XYZ", 1)); + WorkHere(session, "JA9ZZZ", minute: 5); + + Assert.True(session.DeleteFromNetwork("JA1XYZ-1", "JA1XYZ", Start.AddMinutes(1))); + + Qso left = Assert.Single(session.Log.Qsos); + Assert.Equal("JA9ZZZ", left.Call.Text); + Assert.True(left.IsMultiplier1); + Assert.Single(store.Qsos(session.Instance.ContestNumber)); + } + + [Fact] + public void ADeleteOfAContactWeDoNotHoldIsRefused() + { + (ContestSession session, _) = Station(); + + Assert.False(session.DeleteFromNetwork("nothing", "JA1XYZ", Start)); + } +}