diff --git a/docs/REVOLVING_DOOR_PLAN.md b/docs/REVOLVING_DOOR_PLAN.md new file mode 100644 index 0000000..0eb95a9 --- /dev/null +++ b/docs/REVOLVING_DOOR_PLAN.md @@ -0,0 +1,226 @@ +# Revolving-Door Mode — Implementation Plan + +**Status: PLANNED (not started).** Written 2026-07-24 from a read-only feasibility study. +No code has been modified. Implement starting with Phase 1; do NOT start Phase 2 without +the Phase-2 spike passing on a 2-node rig. + +**Goal (operator's words):** "an infinite long mission that players can just spawn in and +out of as they please... they can just go in and out as they please and i'd just leave it +running" — so players generate gameplay logs without coordinating everyone's timeline. + +--- + +## Two tiers + +| | What the player sees | Code touched | Effort | New zip? | +|---|---|---|---|---| +| **Phase 1 — Round Carousel** | Short rounds (10–15 min) run back-to-back forever with whoever is seated; a joiner waits at most one round-length for the next launch | `tools/btconsole.py` (+ small `tools/btoperator.py` UI) only | ~half a day | **NO** — works with 4.11.518 zips as shipped | +| **Phase 2 — True Drop-In** | One endless mission; join and leave instantly mid-mission | engine (`L4NET.CPP`, `HOSTMGR.cpp`, game-side make re-send) + relay | 1-day spike, then 2–4 days | YES | + +Phase 1 fully solves the stated goal (unattended logging, no timeline coordination). +Phase 2 is the pod-faithful dream and also the path a rebooting pod would use mid-session — +build it later, on top of a proven Phase 1. + +--- + +## Feasibility findings (2026-07-24, all read-only — file:line anchors) + +What ALREADY works: + +1. **Back-to-back auto re-arm** — `_check_launch_gate` (`tools/btconsole.py:780-813`): + after a round (`launches_sent==2`), when all seats re-ACK a fresh egg the relay resets + launch state and re-arms. In **auto mode** (`manual_launch=False`) it then schedules the + next LAUNCH pair itself (`:810-813`). The carousel loop essentially exists; it is just + gated on *the full roster* re-ACKing. +2. **Mission clock is console-side** — `_tick_stop` (`btconsole.py:959-983`) sends + StopMission at `[mission] length` expiry. Round length is an egg edit, nothing else. +3. **Mid-round LEAVING works today** — PEER_DOWN teardown, survivors continue, matchlog + auto-uploads on exit. Hardened all through the 4.11.49x–51x work (issues #33/#34/#35; + the interest-teardown crash on this exact path is fixed, commit 210bdb0). +4. **Round reset / seat identity** — `_maybe_reset_round` (`btconsole.py:734-771`) + restores `orig_roster` + template egg when all pods leave; `seat_identity` keeps seats + static per (ip,callsign) (#34). LAUNCH trims the roster to present seats + (`_trim_roster` `:651-677`, writes `_relay_trimmed.egg`). +5. **Transport tolerates late host-online** — relay PEER_UP is bidirectional for + newcomers (`btconsole.py:1202-1209` "newcomer learns everyone; everyone learns + newcomer"); engine `HostConnectedMessageHandler` (`engine/MUNGA_L4/L4NET.CPP:1551+`) + flips an existing ROSTER host to OnLine whenever it connects — different arrival times + are normal (that's how slow loaders work). +6. **The world learns about a JOINER for free** — a joiner creates its player/mech AFTER + connecting, so its entity-creation broadcast reaches everyone present. + +The Phase-2 gap (the ONLY hard part): + +7. **A joiner never learns about the EXISTING world.** Entity MakeMessages are broadcast + once at creation and never retained. `InterestManager::NotifyOfEntityCreation` + (`engine/MUNGA/INTEREST.cpp:694-763`) routes to `DynamicEntityCreation` (`:839+`) which + sends immediately; interest-zone tracking is `#if 0 "HACK - Partial implementation"` + (`:877-898`). `HostManager::NotifyOfReplacementEntityCreation` + (`engine/MUNGA/HOSTMGR.cpp`) is literally `Fail("under construction")` — the 1995 team + designed late-sync and never finished it. [T1] +8. **The engine launch gate stalls on absent roster seats** — an endless mission with an + 8-seat roster cannot launch with 2 present (every pod waits on every roster host). + Phase 1 sidesteps this with the existing launch-time trim; Phase 2 must relax it. + +--- + +## Phase 1 — Round Carousel (console-only) + +### Concept + +A relay **door mode** (`--door` / GUI checkbox → env for the spawned relay): rounds launch +automatically with *whoever is seated*, run for a short fixed length, and the relay +re-forms the next round forever. The operator leaves it running. Players run the normal +`join.bat` from the 518 zip — join between rounds, leave whenever. + +### The one rule door mode changes + +Everywhere the relay today waits for **the full roster**, door mode waits for +**at least MIN_PLAYERS (default 1–2) + a quiet-period join window**: + +``` +door gate: _pods_ready() >= DOOR_MIN_PLAYERS + and (now - last_new_ack) >= DOOR_JOIN_WINDOW # let a 2nd/3rd joiner in + and now >= round_hold_until # keep the #33 settle +``` + +### Changes (all in `tools/btconsole.py` unless noted) + +1. **`--door` flag / `BT_DOOR=1`** → `self.door_mode`, plus knobs + `DOOR_MIN_PLAYERS` (default 2; 1 allowed for solo soak), `DOOR_JOIN_WINDOW` + (default 45 s), `DOOR_ROUND_SECONDS` (default 600; overrides the egg `[mission] length` + via the existing prefs-rewrite path — same mechanism as the console's mission fields, + `:603-622`). +2. **Gate swap** — in `_check_launch_gate` (`:780-813`) and the ALL-READY hold + (`:910-934`): in door mode replace `>= len(self.roster)` with the door gate above. + Track `last_new_ack` on every egg ACK. Auto mode implied (`manual_launch` ignored, or + rather: door mode forces auto). +3. **Roster reset at round END, not all-gone** — today `_maybe_reset_round` (`:734`) + waits for every pod to vanish. In door mode, ALSO reset (restore `orig_roster` + + template egg) as soon as `stop_sent` fires and the post-round rejoin churn begins, so a + *new* player's walk-up can claim a seat for the next round even while last-round + players are still relaunching. **Care:** re-key live beacons exactly as `:737-771` + already does; keep `seat_identity` so returning players get the same seat (#34). +4. **Trim stays** — the existing LAUNCH trim (`:651`) is what makes a partial roster + launchable (finding 8). No change. +5. **Stop → re-arm loop closure** — after `stop_sent`, clear `stop_sent/stop_requested/ + mission_started_at` when the round resets (the `:750-757` and `:791-796` blocks already + do this; verify both fire in door mode's earlier-reset ordering). +6. **Operator visibility** — `[door]` log lines: round number, seated players, next-launch + countdown; surface in the GUI via the existing `_StampedOut` tee / remote control + channel (no new protocol — `get mission` already reports state; add a `door` status + line to it under the existing whitelist mechanism). +7. **`tools/btoperator.py`** — a "Revolving door" checkbox that adds the flag when + spawning the relay (local) or is simply visible state when remote (the remote control + `set` whitelist gains `door`, `door_min`, `door_round` keys via `CONTROL_SET_KEYS`). +8. **Idle behavior** — 0 players seated: relay just sits at the join wait (it already + does); no special case. 1 player with `DOOR_MIN_PLAYERS=2`: waits, logs + `[door] 1 seated, need 2`. + +### What does NOT change + +- Player zips/exe/bats — 518 as shipped. Joining is the normal join.bat flow. +- Classic mode — every door branch is `if self.door_mode:`; default behavior untouched + (tonight's round runs the unmodified console). +- The #33 abort-settle, REJOIN-on-bad-HELLO, storm damper, seat identity — all kept; + door mode layers on top. + +### Risks / edge cases to test + +- **Mid-round joiner walk-up**: a player joining while a round is RUNNING must be parked + cleanly at the join wait (held egg) until the round ends + resets — verify the hold path + doesn't hand them an egg mid-round (today's held-egg logic should cover it; test it). +- **Round reset racing the relaunch churn** (change 3 is the riskiest): last-round pods + relaunch and re-HELLO *while* the roster is being restored. The #33/#34 machinery was + built for exactly this; run the 3-client churn rig against door mode. +- **`_relay_trimmed.egg` staleness** between rounds — reset must re-point `egg_path` at + the template (already done at `:766-771` region; verify in the earlier-reset ordering). +- **Endless operator absence**: crash of one client mid-round → existing abort/settle path + → door mode must re-form the next round without operator input (this is the whole + point — soak it). + +### Test plan (before fielding) + +1. 3-node loopback rig, `DOOR_ROUND_SECONDS=120`: 3 clients seated → autolaunch → round + end → all relaunch → next round autolaunches. 5+ consecutive rounds unattended. +2. Joiner test: start round with 2, connect a 3rd mid-round → parked → seated next round. +3. Leaver test: kill one client mid-round (its own PID only) → survivors finish → next + round forms with 2 → the leaver rejoins a round later. +4. `DOOR_MIN_PLAYERS=1` solo soak overnight: round count in the relay log, zero stalls. +5. Classic-mode regression: run one normal manual-launch round — byte-identical behavior + (no door branches taken; grep the log for `[door]` = zero lines). + +--- + +## Phase 2 — True Drop-In (engine work; LATER) + +**Do not start without the spike.** Everything below is sketch-level [T4 until spiked]. + +### The design + +On a mid-mission PEER_UP for a host with no entities, each machine **re-sends the +MakeMessages for the masters it owns** (its BTPlayer + mech — projectiles are transient +and self-expire). This finishes what `NotifyOfReplacementEntityCreation` left +"under construction", but at the game layer where we control both ends. + +Pieces: + +1. **Late-attach detection** — `HostConnectedMessageHandler` (`L4NET.CPP:1551+`): if + `GetApplicationState()==RunningMission` when a roster host comes online, fire the + re-make pass for locally-owned masters. +2. **Make re-send** — each master rebuilds its `Entity::MakeMessage` (same path + `HostManager::NotifyOfEntityCreation` used at creation, `HOSTMGR.cpp:360-410`) and + sends it addressed/broadcast. NOTE: must verify a make can be regenerated + post-creation (serialize current state, not birth state — that's actually BETTER for a + joiner: current position/damage). +3. **Duplicate-make guard** — peers that already know the entity ID must ignore (or + idempotently accept) the re-make. Check the replicant-creation path for existing-ID + behavior; add a guard if it double-creates. +4. **Relaxed launch gate** — a door-mode env (`BT_DOOR_LAUNCH=1`?) letting the mission + launch with roster hosts still `Opening`; absent seats stay pending instead of + blocking. (Find the exact all-connected wait first — it's the stall the relay warns + about.) +5. **Relay door mode v2** — skip the all-ACK egg hold entirely: hand a joiner the egg + mid-mission, send them RunMission solo (the pair-timing needs thought), PEER_UP does + the rest. +6. **Joiner-side world catch-up** — terrain/map entities are static (loaded from the egg, + `StaticEntityCreation` path) so only dynamic masters need sync. Score state rides the + existing update stream (`Player::WriteUpdateRecord` ForceUpdate — already fixed for + #29). + +### The 1-day spike (go/no-go) + +2-node rig: node A in-mission; node B connects late with a hacked-in gate bypass; node A +manually re-sends its mech make on PEER_UP. **Pass = B sees A's mech and can shoot it.** +If the make can't be cleanly regenerated post-birth, or replicant creation of an existing +ID corrupts state, stop and re-scope. + +### Risk register + +- This is exactly the interest-teardown crash neighborhood (the #35 NULL-vehicle AV, + fixed in `L4AUDIO.cpp`) — every join/leave exercises Orphan/RemoveUninteresting paths. + Soak with join/leave churn + the crash self-report armed. +- `#if 0` partial-implementation interest zones mean interest filtering is effectively + global — fine for our player counts. +- Wire format is a compatibility surface: any new route/message = new exe for EVERYONE + (no mixed-version doors). + +--- + +## Rollout order + +1. **Phase 1** next work session (console-only, no rebuild) → soak on the operator + machine → field it on the next casual night with `DOOR_ROUND_SECONDS=600`. +2. Collect a week of carousel logs (that's the goal being served). +3. **Phase 2 spike** when there's appetite; commit only on a green spike. + +## Morning checklist (verify before coding Phase 1) + +- [ ] Re-read `_check_launch_gate` + ALL-READY hold ordering with door gate in mind + (`btconsole.py:780-813`, `:910-934`). +- [ ] Trace the held-egg path for a walk-up arriving mid-round (change-3 risk). +- [ ] Confirm which of the two reset blocks (`:750-757` vs `:791-796`) must fire on the + door-mode early reset, and that `egg_path` re-points at the template. +- [ ] Confirm `parse_egg_mission_length` override path for `DOOR_ROUND_SECONDS` + (prefs-rewrite at `:603-622`). +- [ ] Grep for every `len(self.roster)` comparison — each is a door-mode decision point.