vPOD: pod power controls, end-mission restart cycle, egg viewer polish
Adds pod-lifecycle controls to the vPOD window: - Power Off / Power On: Power Off closes the TCP listener so the console can no longer connect, mimicking a pod with no game client running; Power On reopens it. The listener close/open is the faithful signal a console sees. - End-mission graceful-exit + watchdog restart: on the end-mission command (StopMission) the game exits gracefully (listener closes, connection drops) and a watchdog relaunches it about 1.5 s later, coming back up in WaitingForEgg - the real pod's per-game cycle. A "Restart game after mission ends (watchdog)" checkbox (default on) toggles it; unchecked, the pod just returns to WaitingForEgg without exiting. - Egg viewer: the last egg is kept across missions/restarts (no auto-clear) so it can be copied for dev use; a Clear button empties it on demand. - Log pane defaults to half the window (egg lines are rarely wide); the split is set at load once the control has its real width. Verified over real TCP: driving egg -> run -> end-mission makes the listener drop then reopen with the pod back in WaitingForEgg (confirmed in vPOD's own log: "Game exited gracefully" then "Watchdog restarted the game"). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ internal sealed class PodSimulator
|
||||
private ApplicationID mApplicationId;
|
||||
private int mHostId;
|
||||
private int mTransitionDelayMs = 400;
|
||||
private bool mRestartOnEndMission = true;
|
||||
|
||||
// Egg reassembly
|
||||
private byte[] mEggBuffer;
|
||||
@@ -49,6 +50,7 @@ internal sealed class PodSimulator
|
||||
public event Action<string> EggReceived; // full egg text (NUL-normalized)
|
||||
public event Action EggProgress; // a chunk arrived (mid-transfer)
|
||||
public event Action<string> Log;
|
||||
public event Action EndMissionExit; // game exited on end mission; watchdog should restart it
|
||||
|
||||
public PodSimulator(MungaPodServer server, ApplicationID applicationId, int hostId)
|
||||
{
|
||||
@@ -77,6 +79,17 @@ internal sealed class PodSimulator
|
||||
set { lock (mLock) { mTransitionDelayMs = Math.Max(0, value); } }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When true (the faithful default), the end-mission command makes the game
|
||||
/// exit and its watchdog restart it (the form drops and reopens the listener).
|
||||
/// When false, the pod simply returns to WaitingForEgg without exiting.
|
||||
/// </summary>
|
||||
public bool RestartOnEndMission
|
||||
{
|
||||
get { lock (mLock) { return mRestartOnEndMission; } }
|
||||
set { lock (mLock) { mRestartOnEndMission = value; } }
|
||||
}
|
||||
|
||||
public string EggText { get { lock (mLock) { return mEggText; } } }
|
||||
|
||||
public int EggPercent
|
||||
@@ -139,11 +152,24 @@ internal sealed class PodSimulator
|
||||
break;
|
||||
|
||||
case StopMissionMessage _:
|
||||
Log?.Invoke("<- StopMissionMessage");
|
||||
Log?.Invoke("<- StopMissionMessage (end mission)");
|
||||
if (State == ApplicationState.RunningMission || State == ApplicationState.SuspendingMission)
|
||||
{
|
||||
SetState(ApplicationState.EndingMission);
|
||||
ScheduleTransition(ApplicationState.WaitingForEgg, ResetEggOnArrive: true);
|
||||
bool restart;
|
||||
lock (mLock) { restart = mRestartOnEndMission; }
|
||||
if (restart)
|
||||
{
|
||||
// Faithful pod behaviour: the game exe exits gracefully on the
|
||||
// end-mission command, then its watchdog restarts it. The form
|
||||
// simulates the exit + restart by dropping and reopening the
|
||||
// listener; the pod comes back up in WaitingForEgg.
|
||||
EndMissionExit?.Invoke();
|
||||
}
|
||||
else
|
||||
{
|
||||
ScheduleTransition(ApplicationState.WaitingForEgg, ResetEggOnArrive: true);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
+11
-1
@@ -16,9 +16,19 @@ shows everything on a live display.
|
||||
`WaitingForEgg → LoadingMission → WaitingForLaunch → LaunchingMission →
|
||||
RunningMission → …`, reacting to the egg stream and to Run / Stop / Abort /
|
||||
Suspend / Resume messages, and acknowledging the egg.
|
||||
- **End-mission graceful exit + watchdog restart** — on the console's end-mission
|
||||
command the "game exe" exits (the listener closes, the console's connection
|
||||
drops), then a watchdog relaunches it a moment later and it comes back up in
|
||||
`WaitingForEgg`. This is the real pod's per-game cycle (`autoRestart`); the
|
||||
*Restart game after mission ends (watchdog)* checkbox (on by default) toggles
|
||||
it — unchecked, the pod just returns to `WaitingForEgg` without exiting.
|
||||
- **Power On / Power Off** — Power Off closes the TCP listener so the console
|
||||
cannot connect, mimicking a pod with no game client running; Power On reopens
|
||||
it. **Reset** returns a live pod to `WaitingForEgg`.
|
||||
- **Reassembles and shows the egg** the console streams (the `EggFileMessage`
|
||||
chunks), one field per line, with a summary line (adventure / map / scenario /
|
||||
pilot count).
|
||||
pilot count). The last egg is **kept** across missions/restarts (so it can be
|
||||
copied for dev use) until the **Clear** button empties the viewer.
|
||||
- **Game toggle** — a Red Planet ⇄ BattleTech switch on the window changes which
|
||||
`ApplicationID` the pod reports, live, so one vPOD can stand in for either
|
||||
game. (`-app rp|bt` sets the initial choice.)
|
||||
|
||||
+152
-12
@@ -27,10 +27,17 @@ internal sealed class VPodForm : Form
|
||||
private RadioButton mRedPlanetRadio;
|
||||
private RadioButton mBattleTechRadio;
|
||||
private Button mPowerButton;
|
||||
private Button mPowerOffButton;
|
||||
private Button mResetButton;
|
||||
private CheckBox mRestartCheckbox;
|
||||
private Timer mRestartTimer;
|
||||
private SplitContainer mSplit;
|
||||
private TextBox mEggBox;
|
||||
private TextBox mLogBox;
|
||||
|
||||
// How long the "watchdog" waits before relaunching the exited game.
|
||||
private const int WatchdogRestartMs = 1500;
|
||||
|
||||
public VPodForm(PodArguments options)
|
||||
{
|
||||
mOptions = options;
|
||||
@@ -45,6 +52,10 @@ internal sealed class VPodForm : Form
|
||||
mSimulator.StateChanged += OnStateChanged;
|
||||
mSimulator.EggReceived += OnEggReceived;
|
||||
mSimulator.EggProgress += OnEggProgress;
|
||||
mSimulator.EndMissionExit += OnEndMissionExit;
|
||||
|
||||
mRestartTimer = new Timer { Interval = WatchdogRestartMs };
|
||||
mRestartTimer.Tick += OnRestartTimerTick;
|
||||
|
||||
Load += OnFormLoad;
|
||||
FormClosing += OnFormClosing;
|
||||
@@ -62,7 +73,7 @@ internal sealed class VPodForm : Form
|
||||
{
|
||||
Text = "Pod Status",
|
||||
Dock = DockStyle.Top,
|
||||
Height = 168,
|
||||
Height = 176,
|
||||
Padding = new Padding(10)
|
||||
};
|
||||
|
||||
@@ -113,32 +124,64 @@ internal sealed class VPodForm : Form
|
||||
};
|
||||
mRedPlanetRadio.CheckedChanged += GameToggleChanged;
|
||||
mBattleTechRadio.CheckedChanged += GameToggleChanged;
|
||||
mPowerButton = new Button { Text = "Power On", Location = new Point(18, 88), Size = new Size(90, 28) };
|
||||
mResetButton = new Button { Text = "Reset", Location = new Point(116, 88), Size = new Size(90, 28) };
|
||||
mPowerButton.Click += (s, e) => mSimulator.PowerOn();
|
||||
mPowerButton = new Button { Text = "Power On", Location = new Point(12, 90), Size = new Size(94, 28) };
|
||||
mPowerOffButton = new Button { Text = "Power Off", Location = new Point(112, 90), Size = new Size(94, 28) };
|
||||
mResetButton = new Button { Text = "Reset", Location = new Point(212, 90), Size = new Size(94, 28) };
|
||||
mPowerButton.Click += PowerOnClicked;
|
||||
mPowerOffButton.Click += PowerOffClicked;
|
||||
mResetButton.Click += (s, e) => mSimulator.Reset();
|
||||
gameGroup.Controls.Add(mRedPlanetRadio);
|
||||
gameGroup.Controls.Add(mBattleTechRadio);
|
||||
gameGroup.Controls.Add(mPowerButton);
|
||||
gameGroup.Controls.Add(mPowerOffButton);
|
||||
gameGroup.Controls.Add(mResetButton);
|
||||
|
||||
mRestartCheckbox = new CheckBox
|
||||
{
|
||||
Text = "Restart game after mission ends (watchdog)",
|
||||
Location = new Point(16, 146),
|
||||
AutoSize = true,
|
||||
Checked = true
|
||||
};
|
||||
mRestartCheckbox.CheckedChanged += (s, e) => mSimulator.RestartOnEndMission = mRestartCheckbox.Checked;
|
||||
|
||||
statusGroup.Controls.Add(mListeningLabel);
|
||||
statusGroup.Controls.Add(mConnectionLabel);
|
||||
statusGroup.Controls.Add(roleLabel);
|
||||
statusGroup.Controls.Add(stateCaption);
|
||||
statusGroup.Controls.Add(mStateLabel);
|
||||
statusGroup.Controls.Add(mRestartCheckbox);
|
||||
statusGroup.Controls.Add(gameGroup);
|
||||
|
||||
// ---- egg viewer + log ----
|
||||
SplitContainer split = new SplitContainer
|
||||
// Split the egg viewer (left) and protocol log (right). The 50/50 default
|
||||
// is applied in OnFormLoad once the control has its real width (setting it
|
||||
// here would clamp to the control's default size).
|
||||
mSplit = new SplitContainer
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
SplitterDistance = 480,
|
||||
Orientation = Orientation.Vertical
|
||||
};
|
||||
SplitContainer split = mSplit;
|
||||
|
||||
GroupBox eggGroup = new GroupBox { Text = "Current Egg", Dock = DockStyle.Fill, Padding = new Padding(8) };
|
||||
mEggSummaryLabel = new Label { Dock = DockStyle.Top, Height = 40, Text = "No egg loaded." };
|
||||
// The egg is kept after a mission/restart (not auto-cleared) so it can be
|
||||
// copied for dev use; the Clear button empties the viewer on demand.
|
||||
Panel eggHeader = new Panel { Dock = DockStyle.Top, Height = 30 };
|
||||
Button clearEggButton = new Button { Text = "Clear", Dock = DockStyle.Right, Width = 70 };
|
||||
mEggSummaryLabel = new Label
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Text = "No egg loaded.",
|
||||
TextAlign = ContentAlignment.MiddleLeft
|
||||
};
|
||||
clearEggButton.Click += (s, e) =>
|
||||
{
|
||||
mEggBox.Clear();
|
||||
mEggSummaryLabel.Text = "No egg loaded.";
|
||||
};
|
||||
eggHeader.Controls.Add(mEggSummaryLabel);
|
||||
eggHeader.Controls.Add(clearEggButton);
|
||||
mEggBox = new TextBox
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
@@ -150,7 +193,7 @@ internal sealed class VPodForm : Form
|
||||
BackColor = Color.White
|
||||
};
|
||||
eggGroup.Controls.Add(mEggBox);
|
||||
eggGroup.Controls.Add(mEggSummaryLabel);
|
||||
eggGroup.Controls.Add(eggHeader);
|
||||
|
||||
GroupBox logGroup = new GroupBox { Text = "Protocol Log", Dock = DockStyle.Fill, Padding = new Padding(8) };
|
||||
mLogBox = new TextBox
|
||||
@@ -175,14 +218,98 @@ internal sealed class VPodForm : Form
|
||||
|
||||
private void OnFormLoad(object sender, EventArgs e)
|
||||
{
|
||||
UpdateListeningLabel(false);
|
||||
// Now that the split has its real width, give the log half the window.
|
||||
if (mSplit.Width > mSplit.Panel1MinSize + mSplit.Panel2MinSize)
|
||||
{
|
||||
mSplit.SplitterDistance = mSplit.Width / 2;
|
||||
}
|
||||
UpdateConnectionLabel(null);
|
||||
UpdateStateLabel(mSimulator.State);
|
||||
if (StartServer())
|
||||
{
|
||||
mSimulator.PowerOn();
|
||||
SetPoweredState(on: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetPoweredState(on: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFormClosing(object sender, FormClosingEventArgs e)
|
||||
{
|
||||
mServer.Stop();
|
||||
}
|
||||
|
||||
private void PowerOnClicked(object sender, EventArgs e)
|
||||
{
|
||||
mRestartTimer.Stop(); // cancel any pending watchdog restart
|
||||
if (!mServer.IsListening && !StartServer())
|
||||
{
|
||||
return; // couldn't bind the port; stay powered off
|
||||
}
|
||||
mSimulator.PowerOn();
|
||||
SetPoweredState(on: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Powers the pod off by closing the TCP listener, so the console can no
|
||||
/// longer connect — the same condition as a pod with no game client running.
|
||||
/// </summary>
|
||||
private void PowerOffClicked(object sender, EventArgs e)
|
||||
{
|
||||
mRestartTimer.Stop();
|
||||
mServer.Stop();
|
||||
SetPoweredState(on: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The game exited gracefully on the console's end-mission command. Simulate
|
||||
/// the process exit by closing the listener (the console's connection drops),
|
||||
/// then let the watchdog timer relaunch it.
|
||||
/// </summary>
|
||||
private void OnEndMissionExit()
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
BeginInvoke((Action)(() =>
|
||||
{
|
||||
OnLog("Game exited gracefully (end mission); watchdog will restart it...");
|
||||
mServer.Stop();
|
||||
UpdateListeningLabel(false);
|
||||
UpdateConnectionLabel(null);
|
||||
mStateLabel.Text = "Restarting...";
|
||||
mStateLabel.ForeColor = Color.DarkOrange;
|
||||
mPowerButton.Enabled = true;
|
||||
mPowerOffButton.Enabled = false;
|
||||
mResetButton.Enabled = false;
|
||||
mRestartTimer.Stop();
|
||||
mRestartTimer.Start();
|
||||
}));
|
||||
}
|
||||
|
||||
private void OnRestartTimerTick(object sender, EventArgs e)
|
||||
{
|
||||
mRestartTimer.Stop();
|
||||
if (StartServer())
|
||||
{
|
||||
OnLog("Watchdog restarted the game.");
|
||||
mSimulator.PowerOn();
|
||||
SetPoweredState(on: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetPoweredState(on: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts the listener and updates the status label. Returns false (with a message) if the port is unavailable.</summary>
|
||||
private bool StartServer()
|
||||
{
|
||||
try
|
||||
{
|
||||
mServer.Start();
|
||||
UpdateListeningLabel(true);
|
||||
mSimulator.PowerOn();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -192,12 +319,23 @@ internal sealed class VPodForm : Form
|
||||
"Could not listen on TCP port " + mOptions.Port + ".\n\n" + ex.Message +
|
||||
"\n\nIs another pod or vPOD already using it?",
|
||||
"vPOD", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFormClosing(object sender, FormClosingEventArgs e)
|
||||
/// <summary>Reflects the on/off state in the buttons and, when off, the status labels.</summary>
|
||||
private void SetPoweredState(bool on)
|
||||
{
|
||||
mServer.Stop();
|
||||
mPowerButton.Enabled = !on;
|
||||
mPowerOffButton.Enabled = on;
|
||||
mResetButton.Enabled = on;
|
||||
if (!on)
|
||||
{
|
||||
UpdateListeningLabel(false);
|
||||
UpdateConnectionLabel(null);
|
||||
mStateLabel.Text = "Powered Off";
|
||||
mStateLabel.ForeColor = Color.Gray;
|
||||
}
|
||||
}
|
||||
|
||||
private void GameToggleChanged(object sender, EventArgs e)
|
||||
@@ -251,6 +389,8 @@ internal sealed class VPodForm : Form
|
||||
private void OnStateChanged(ApplicationState state)
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
// The egg viewer is intentionally NOT cleared here — the last egg stays
|
||||
// visible (copyable) across missions/restarts until the Clear button.
|
||||
BeginInvoke((Action)(() => UpdateStateLabel(state)));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user