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
227 lines
13 KiB
Markdown
227 lines
13 KiB
Markdown
# 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.
|