diff --git a/Console/vPOD/PodSimulator.cs b/Console/vPOD/PodSimulator.cs index 48eb39e..554ca2d 100644 --- a/Console/vPOD/PodSimulator.cs +++ b/Console/vPOD/PodSimulator.cs @@ -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 EggReceived; // full egg text (NUL-normalized) public event Action EggProgress; // a chunk arrived (mid-transfer) public event Action 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); } } } + /// + /// 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. + /// + 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; diff --git a/Console/vPOD/README.md b/Console/vPOD/README.md index 24e8e66..4753935 100644 --- a/Console/vPOD/README.md +++ b/Console/vPOD/README.md @@ -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.) diff --git a/Console/vPOD/VPodForm.cs b/Console/vPOD/VPodForm.cs index 662ec2a..505107d 100644 --- a/Console/vPOD/VPodForm.cs +++ b/Console/vPOD/VPodForm.cs @@ -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); + } + + /// + /// 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. + /// + private void PowerOffClicked(object sender, EventArgs e) + { + mRestartTimer.Stop(); + mServer.Stop(); + SetPoweredState(on: false); + } + + /// + /// 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. + /// + 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); + } + } + + /// Starts the listener and updates the status label. Returns false (with a message) if the port is unavailable. + 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) + /// Reflects the on/off state in the buttons and, when off, the status labels. + 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))); }