Files
BT411/docs/REVOLVING_DOOR_PLAN.md
T
arcattackandClaude Opus 5 66d1410118 Revolving-door mode: feasibility findings + two-phase implementation plan (docs only)
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
2026-07-24 15:05:06 -05:00

227 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (1015 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 24 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.49x51x 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 12) + 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.