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)); + } +}