Console: internet-session roster from TeslaLobby; vPOD per-address bind

The eight internet pod rows are furniture the operator builds once in
Manage Site — the slot-to-IP map is frozen — but which of those slots a
human actually claimed changes every session. New SessionRoster reads the
roster TeslaLobby writes at the state=launching flip, and both game panes
grow a session strip above Mission Properties: a banner (session key, game,
slots claimed, waiting, written-at), Apply Session, and the Reset Pods that
until now existed only as a right-click on a Go button that is disabled
exactly when the reset is wanted.

Two properties shape all of it. Only claimed slots appear in the file, so
absence is the unclaimed signal and every failure path — truncated, stale,
unreadable, refused — degrades to "no roster", which is byte-for-byte
today's arcade behaviour; museums run this software and a bad JSON file
must never stop a mission that would otherwise run by hand. And enabled
implies claimed, not the reverse: the operator may always sit a pilot out,
never add one, because an enabled row nobody claimed puts a dead IP in the
egg and the pods then wait on a peer that will never boot. Roster issues
join the pane's existing issue text, so the Go button is still the gate.

The roster is one file per launch generation and deliberately not a live
view: pod peer tables are boot-static, so a player whose lobby crashed is
still in every pod's table and still playable, and live tracking would
evict that working pod mid-session. Poll runs at ~1Hz off the existing
network timer with its own deadline, and the strips are built at runtime —
InitializeComponent is decompiled 1995 designer output that the
differential tests compare literally.

Go/Load also re-checks CheckAllValues at the click instead of trusting the
last status tick: a pod that died in that gap went straight into the
mission, and the post-Load barrier in NetworkScan then waited forever for a
WaitingForLaunch that never arrives.

vPOD gains -bind <ip>. Both listeners defaulted to IPAddress.Any, so a
second vPOD on the machine lost the port and the console could only ever
see one fake pod; binding each instance to its own address runs a whole
eight-pod session side by side with no cockpits. Bind all of them or none —
Windows lets IPAddress.Any take a port that specific addresses already
hold, and the unbound instance then answers for every slot nobody claimed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-28 13:14:38 -05:00
co-authored by Claude Opus 5
parent 3d186293bf
commit 33f734da2d
7 changed files with 1228 additions and 7 deletions
+156
View File
@@ -184,6 +184,18 @@ internal class BTGame : DockContent
private readonly bool mDosBoxAddressShift = BTDefaults.DosBoxAddressShift;
private Panel mSessionStrip;
private Label mSessionBanner;
private Button mSessionApply;
private Button mSessionReset;
private DateTime mNextRosterPoll = DateTime.MinValue;
private DateTime mStateChangePendingSince = DateTime.MinValue;
private string GameStatusText
{
set
@@ -300,10 +312,117 @@ internal class BTGame : DockContent
{
BuildPodRow(item5.A, item5.B);
}
BuildSessionStrip();
SessionRoster.Poll();
UpdateSessionStrip();
CheckAllValues();
ResumeLayout();
}
/// <summary>
/// The internet-session strip: roster banner, Apply, and the Reset that has
/// always existed as a right-click on the (disabled) Go button.
///
/// Built here and not in InitializeComponent on purpose. That block is the
/// decompiled 1995 designer output and the differential tests compare it
/// literally, so anything new has to be assembled at runtime instead.
/// Docked last, which is docked first, so the strip sits above Mission
/// Properties rather than between it and the pilot grid.
/// </summary>
private void BuildSessionStrip()
{
mSessionStrip = new Panel();
mSessionStrip.Dock = DockStyle.Top;
mSessionStrip.Height = 28;
mSessionStrip.Visible = false;
mSessionBanner = new Label();
mSessionBanner.Dock = DockStyle.Fill;
mSessionBanner.TextAlign = ContentAlignment.MiddleLeft;
mSessionBanner.Padding = new Padding(6, 0, 6, 0);
mSessionApply = new Button();
mSessionApply.Dock = DockStyle.Left;
mSessionApply.Width = 110;
mSessionApply.Text = "Apply Session";
mSessionApply.Visible = false;
mSessionApply.UseVisualStyleBackColor = true;
mSessionApply.Click += new EventHandler(mSessionApply_Click);
mSessionReset = new Button();
mSessionReset.Dock = DockStyle.Right;
mSessionReset.Width = 110;
mSessionReset.Text = "Reset Pods";
mSessionReset.Visible = false;
mSessionReset.UseVisualStyleBackColor = true;
// The same action as the hidden context-menu item, which no operator has
// ever found: it lives on a button that is disabled exactly when the reset
// is wanted.
mSessionReset.Click += new EventHandler(resetToolStripMenuItem_Click);
mSessionStrip.Controls.Add(mSessionBanner);
mSessionStrip.Controls.Add(mSessionApply);
mSessionStrip.Controls.Add(mSessionReset);
base.Controls.Add(mSessionStrip);
}
private void UpdateSessionStrip()
{
if (mRequestedState == mCurrentState)
{
mStateChangePendingSince = DateTime.MinValue;
mSessionReset.Visible = false;
}
else
{
if (mStateChangePendingSince == DateTime.MinValue)
{
mStateChangePendingSince = DateTime.Now;
}
// Ten seconds, so a healthy Load/Launch never makes the button flicker
// past. Past that the pane is not slow, it is stuck: the state barrier
// in NetworkScan has no timeout of its own.
mSessionReset.Visible = mStateChangePendingSince.AddSeconds(10.0) < DateTime.Now;
}
string text = SessionBannerText();
mSessionBanner.Text = text;
mSessionBanner.ForeColor = (SessionRoster.Active ? SystemColors.ControlText : Color.Red);
mSessionApply.Visible = SessionRoster.Active;
// Nothing claimed, nothing wrong and nothing stuck: no roster file at all
// leaves the pane looking exactly as it does today. Museums run this.
mSessionStrip.Visible = text.Length > 0 || mSessionReset.Visible;
}
private static string SessionBannerText()
{
if (!SessionRoster.Active)
{
// Empty with no file; when a file was refused this is why, and the
// operator needs to read it before hand-enabling eight rows.
return SessionRoster.LoadError;
}
string text = SessionRoster.SessionKey;
if (text.Length > 8)
{
text = text.Substring(0, 8);
}
// Local time: the lobby writes the file on this machine, and the operator
// is comparing the stamp against the clock on the wall.
return $"SESSION {text} - {SessionRoster.Game} - {SessionRoster.Claims.Count} slots claimed - {SessionRoster.WaitingCount} waiting - written {SessionRoster.WrittenUtc.ToLocalTime():HH:mm:ss}";
}
private void mSessionApply_Click(object sender, EventArgs e)
{
string text = SessionRoster.Validate("bt", mDosBoxAddressShift, mPilotsDataGrid, mEnabledColumn.Index, mPilotColumn.Index);
if (text.Length > 0)
{
// Named offender, never a silent fix: the pilot name is about to become
// an INI section name in the egg, so the operator must see the name that
// will be written.
MessageBox.Show(text, "Can Not Apply Session Roster");
return;
}
SessionRoster.Apply(mPilotsDataGrid, mEnabledColumn.Index, mPilotColumn.Index, this, mDosBoxAddressShift);
CheckAllValues();
mPilotsDataGrid.Refresh();
}
private static void SetupKeyValueColumn(DataGridViewComboBoxColumn column, Dictionary<string, string> options, string defaultKey)
{
int num = 0;
@@ -403,6 +522,21 @@ internal class BTGame : DockContent
private void NetworkScan(object sender, EventArgs e)
{
// Own deadline rather than a count of ticks: mNetworkTimer's interval is
// the designer default and this must stay ~1Hz whatever that becomes. Same
// shape as MungaGame.QueryStateIfNeeded (MungaGame.cs:159-167).
DateTime now = DateTime.Now;
if (mNextRosterPoll < now)
{
mNextRosterPoll = now.AddSeconds(1.0);
if (SessionRoster.Poll() && mCurrentState == BTGameState.Idle && mRequestedState == BTGameState.Idle)
{
// Guarded like SetPodStatus: CheckAllValues owns mGoButton.Enabled,
// and mid-mission that button is Stop Mission.
CheckAllValues();
}
}
UpdateSessionStrip();
if (mCurrentState == BTGameState.Run && mRequestedState == BTGameState.Run && !mProcessingMissionEndStateChangeReq)
{
mProcessingMissionEndStateChangeReq = true;
@@ -735,6 +869,21 @@ internal class BTGame : DockContent
{
case BTGameState.Load:
{
// Re-check at the click, not at the last status tick. Nothing between
// here and the egg validates anything -- mGoButton.Enabled is a UI flag
// CheckAllValues set at some earlier moment -- so a pod that died in
// that gap goes straight into the mission, and the post-Load barrier in
// NetworkScan then waits forever for a WaitingForLaunch that will never
// arrive (that loop has no timeout). Safe only at the very top of this
// case: mMissionLength, mMissionRecorder, mMissionPlayers,
// mRequestedState and SwitchControlsMode are all still untouched below,
// so returning here leaves the pane exactly as the operator left it.
CheckAllValues();
if (!mGoButton.Enabled)
{
MessageBox.Show(mIssuesLabel.Text, "Can Not Load Mission");
return;
}
mMissionLength = ParseMissionLength();
string key = ((BTMap)mMap.SelectedItem).Key;
string key2 = ((KeyValuePair<string, string>)mTimeOfDay.SelectedItem).Key;
@@ -854,6 +1003,9 @@ internal class BTGame : DockContent
private void SwitchControlsMode(bool editMode)
{
// Applying a roster rewrites pilot names and enabled rows, so it is an edit
// like any other and rides the same gate as the rest of the pane.
mSessionApply.Enabled = editMode;
mMap.Enabled = editMode;
mWeather.Enabled = editMode;
mTimeOfDay.Enabled = editMode;
@@ -1004,6 +1156,10 @@ internal class BTGame : DockContent
{
stringBuilder.AppendLine("The mission length must be at least 10 seconds.");
}
// Enabled implies claimed: an enabled row nobody took in the lobby puts a
// dead IP in the egg and every pod then waits on a peer that never boots.
// Empty with no session roster, which is what keeps the arcade path intact.
stringBuilder.Append(SessionRoster.Issues(mPilotsDataGrid, mEnabledColumn.Index, mDosBoxAddressShift));
mGoButton.Enabled = stringBuilder.Length <= 0;
mIssuesLabel.Text = stringBuilder.ToString();
}