Phase 1 = console-only round carousel (relay door mode, no new zip); Phase 2 = true drop-in (engine make re-send on late PEER_UP), gated on a 2-node spike. No code modified -- plan for the next work session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
13 KiB
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:
- 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. - Mission clock is console-side —
_tick_stop(btconsole.py:959-983) sends StopMission at[mission] lengthexpiry. Round length is an egg edit, nothing else. - 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). - Round reset / seat identity —
_maybe_reset_round(btconsole.py:734-771) restoresorig_roster+ template egg when all pods leave;seat_identitykeeps seats static per (ip,callsign) (#34). LAUNCH trims the roster to present seats (_trim_roster:651-677, writes_relay_trimmed.egg). - Transport tolerates late host-online — relay PEER_UP is bidirectional for
newcomers (
btconsole.py:1202-1209"newcomer learns everyone; everyone learns newcomer"); engineHostConnectedMessageHandler(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). - 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):
- 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 toDynamicEntityCreation(:839+) which sends immediately; interest-zone tracking is#if 0 "HACK - Partial implementation"(:877-898).HostManager::NotifyOfReplacementEntityCreation(engine/MUNGA/HOSTMGR.cpp) is literallyFail("under construction")— the 1995 team designed late-sync and never finished it. [T1] - 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)
--doorflag /BT_DOOR=1→self.door_mode, plus knobsDOOR_MIN_PLAYERS(default 2; 1 allowed for solo soak),DOOR_JOIN_WINDOW(default 45 s),DOOR_ROUND_SECONDS(default 600; overrides the egg[mission] lengthvia the existing prefs-rewrite path — same mechanism as the console's mission fields,:603-622).- 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. Tracklast_new_ackon every egg ACK. Auto mode implied (manual_launchignored, or rather: door mode forces auto). - Roster reset at round END, not all-gone — today
_maybe_reset_round(:734) waits for every pod to vanish. In door mode, ALSO reset (restoreorig_roster+ template egg) as soon asstop_sentfires 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-771already does; keepseat_identityso returning players get the same seat (#34). - Trim stays — the existing LAUNCH trim (
:651) is what makes a partial roster launchable (finding 8). No change. - Stop → re-arm loop closure — after
stop_sent, clearstop_sent/stop_requested/ mission_started_atwhen the round resets (the:750-757and:791-796blocks already do this; verify both fire in door mode's earlier-reset ordering). - Operator visibility —
[door]log lines: round number, seated players, next-launch countdown; surface in the GUI via the existing_StampedOuttee / remote control channel (no new protocol —get missionalready reports state; add adoorstatus line to it under the existing whitelist mechanism). 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 controlsetwhitelist gainsdoor,door_min,door_roundkeys viaCONTROL_SET_KEYS).- 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.eggstaleness between rounds — reset must re-pointegg_pathat the template (already done at:766-771region; 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)
- 3-node loopback rig,
DOOR_ROUND_SECONDS=120: 3 clients seated → autolaunch → round end → all relaunch → next round autolaunches. 5+ consecutive rounds unattended. - Joiner test: start round with 2, connect a 3rd mid-round → parked → seated next round.
- Leaver test: kill one client mid-round (its own PID only) → survivors finish → next round forms with 2 → the leaver rejoins a round later.
DOOR_MIN_PLAYERS=1solo soak overnight: round count in the relay log, zero stalls.- 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:
- Late-attach detection —
HostConnectedMessageHandler(L4NET.CPP:1551+): ifGetApplicationState()==RunningMissionwhen a roster host comes online, fire the re-make pass for locally-owned masters. - Make re-send — each master rebuilds its
Entity::MakeMessage(same pathHostManager::NotifyOfEntityCreationused 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). - 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.
- Relaxed launch gate — a door-mode env (
BT_DOOR_LAUNCH=1?) letting the mission launch with roster hosts stillOpening; absent seats stay pending instead of blocking. (Find the exact all-connected wait first — it's the stall the relay warns about.) - 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.
- Joiner-side world catch-up — terrain/map entities are static (loaded from the egg,
StaticEntityCreationpath) so only dynamic masters need sync. Score state rides the existing update stream (Player::WriteUpdateRecordForceUpdate — 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 0partial-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
- 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. - Collect a week of carousel logs (that's the goal being served).
- 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-757vs:791-796) must fire on the door-mode early reset, and thategg_pathre-points at the template. - Confirm
parse_egg_mission_lengthoverride path forDOOR_ROUND_SECONDS(prefs-rewrite at:603-622). - Grep for every
len(self.roster)comparison — each is a door-mode decision point.