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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-30 22:40:33 +00:00
parent 9b84b103ac
commit 98d51ab7f5
6 changed files with 288 additions and 40 deletions

View File

@@ -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.