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

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

View File

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

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.

View File

@@ -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);

View File

@@ -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<Qso, QsoEdit> change)
{
Qso before = Log.Qsos.FirstOrDefault(q => q.Id == id)

View File

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