D1: relay-assigned seats -- players never need a player number
A pod launched with no BT_SELF asks the relay for a seat before joining: new control frames SEAT_REQUEST (-6) -> relay reserves the lowest roster seat not claimed or reserved (60s reservation so simultaneous joiners can't race onto one seat) -> SEAT_ASSIGN (-7, int32 hostID + NUL tag) becomes relaySelf; everything downstream (egg self-match, HELLO) runs exactly as if BT_SELF had been set. Roster full -> SEAT_FULL (-8) -> clean Fail(). A real HELLO pops the reservation; a pod drop frees the seat. Explicit BT_SELF still claims a specific seat (the operator's local launches use it). Client: L4NetworkManager::RelayRequestSeat (throwaway TCP dial to the relay game port, 10s reply window). Relay: SEAT_REQUEST branch in _handle_game_frame. Operator exporter collapsed from per-seat join_as_playerN.bat to ONE universal join.bat (+ join_lan.bat with BT_RELAY=auto) -- every player gets the same file, first come first served, the arcade walk-up-to-a-pod model. players/ regenerated. Verified: 8/8 seat stub tests (distinct seats in roster order, FULL on exhaustion, claimed+reserved stays FULL, HELLO claim accepted, duplicate HELLO refused); 2-node localhost e2e with NO BT_SELF on either pod -> both seated, full ladder, RunMission pair, UDP flowing post-launch; regression smoke: explicit-BT_SELF relay session clean with zero seat requests, classic mesh (no BT_RELAY) un-regressed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
efd440c64b
commit
f04e8019c2
+22
-5
@@ -265,8 +265,10 @@ Base fns in every subsystem vtable: slot 6 = 41bd34 (Read/apply), slot 7 = 41c50
|
||||
Opt-in relay/dispatcher topology so pods play over the internet behind NAT with no port
|
||||
forwarding, WHILE the authentic distributed-authority simulation is UNTOUCHED (each pod still
|
||||
masters its own mech; peers stay replicants). Env-gated: **`BT_RELAY=<host>:<consolePort>`**
|
||||
(host may be a DNS name; game port = +1; UDP = same number) + **`BT_SELF=<exact [pilots]
|
||||
entry>`** (NIC matching can't identify a pod across NAT). BT_RELAY unset ⇒ every relay branch
|
||||
(host may be a DNS name; game port = +1; UDP = same number). Identity: **`BT_SELF=<exact
|
||||
[pilots] entry>`** claims a specific seat (NIC matching can't identify a pod across NAT);
|
||||
BT_SELF absent/`auto` ⇒ the RELAY assigns the seat (see RELAY-ASSIGNED SEATS below) — players
|
||||
never need to know a player number. BT_RELAY unset ⇒ every relay branch
|
||||
is dead ⇒ the classic mesh is byte-for-byte unchanged (mesh smoke-tested un-regressed each
|
||||
phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`.
|
||||
- **Topology**: every pod makes ONE outbound TCP connection to the relay (an extension of
|
||||
@@ -311,13 +313,28 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`.
|
||||
keep the explicit host:port. Verified 2-node: both pods `auto`-discovered through a real
|
||||
interface and reached RunningMission. Relay side degrades gracefully if the discovery port is
|
||||
taken.
|
||||
- **RELAY-ASSIGNED SEATS (2026-07-18) [T2]**: no BT_SELF ⇒ the pod's NetworkManager ctor makes a
|
||||
short throwaway TCP dial to the relay GAME port and sends `SEAT_REQUEST` (route −6, empty);
|
||||
the relay reserves the lowest roster seat that is neither claimed (registered HELLO) nor
|
||||
reserved (60s window, so simultaneous joiners can't race onto one seat) and answers
|
||||
`SEAT_ASSIGN` (route −7, `{int32 hostID}` + NUL-terminated `[pilots]` tag) — the tag becomes
|
||||
`relaySelf` and everything downstream (egg self-match in StartConnecting, HELLO) proceeds
|
||||
exactly as if BT_SELF had been set. Roster full ⇒ `SEAT_FULL` (route −8) ⇒ clean
|
||||
`Fail()`. A real HELLO claiming the seat pops the reservation; a pod drop (PEER_DOWN) frees
|
||||
the seat for the next joiner. First-come-first-served — the arcade walk-up-to-a-pod model.
|
||||
Client: `L4NetworkManager::RelayRequestSeat` (L4NET.CPP); relay: `_handle_game_frame`
|
||||
SEAT_REQUEST branch (btconsole.py). Verified: 8/8 stub tests (distinct seats, FULL on
|
||||
exhaustion, claim clears reserve, duplicate-HELLO refused) + 2-node localhost e2e with NO
|
||||
BT_SELF on either pod → both seated, full ladder, launch, UDP flowing.
|
||||
- **OPERATOR CONSOLE (2026-07-18)**: `tools/btoperator.py` (PySide6) — the lost 1995 operator
|
||||
station recreated: mission editor with ALL values validated live from BTL4.RES
|
||||
(`tools/eggmodel.py`: maps=type14∩26, colors/badges/patches from the type-25 vehicletable,
|
||||
vehicles from ModelLists), relay/mesh mode switch, live pod-status lights parsed from the
|
||||
console/relay stdout, one-click local instance launcher, per-player `join_as_playerN.bat` (+
|
||||
`_lan.bat` with `BT_RELAY=auto`) export. Wire logic stays in btconsole.py/eggmodel.py (GUI =
|
||||
UI + QProcess only). E2E-verified: app-driven relay session → 2 local pods → LAUNCH observed.
|
||||
console/relay stdout, one-click local instance launcher, and a single universal
|
||||
`join.bat` (+ `join_lan.bat` with `BT_RELAY=auto`) export — no BT_SELF inside, every player
|
||||
gets the SAME file, the relay seats them (`players/`). Wire logic stays in
|
||||
btconsole.py/eggmodel.py (GUI = UI + QProcess only). E2E-verified: app-driven relay session →
|
||||
2 local pods → LAUNCH observed.
|
||||
- **STILL UNTESTED (config, not code)**: a real two-physical-machine / internet run (only the
|
||||
relay's console+game ports need to be reachable inbound — one forwarded port total; or a
|
||||
playit.gg-style tunnel in front of the relay, host-side only).
|
||||
|
||||
@@ -84,7 +84,10 @@ enum
|
||||
RELAY_ROUTE_HELLO = -2, // first frame: magic + our hostID
|
||||
RELAY_ROUTE_PEER_UP = -3, // relay->pod: hostID registered
|
||||
RELAY_ROUTE_PEER_DOWN = -4, // relay->pod: hostID gone
|
||||
RELAY_ROUTE_UDP_ACK = -5 // relay->pod: UDP HELLO acknowledged
|
||||
RELAY_ROUTE_UDP_ACK = -5, // relay->pod: UDP HELLO acknowledged
|
||||
RELAY_ROUTE_SEAT_REQUEST= -6, // pod->relay: assign me a free seat
|
||||
RELAY_ROUTE_SEAT_ASSIGN = -7, // relay->pod: int32 hostID + NUL tag
|
||||
RELAY_ROUTE_SEAT_FULL = -8 // relay->pod: roster full
|
||||
};
|
||||
#define RELAY_HELLO_MAGIC 0x31525442 // 'BTR1' little-endian
|
||||
// LAN auto-discovery (BT_RELAY=auto): probe broadcast on this UDP port; the
|
||||
@@ -388,12 +391,25 @@ L4NetworkManager::L4NetworkManager():
|
||||
relayGameAddress.sin_port =
|
||||
htons((unsigned short)(ntohs(relayConsoleAddress.sin_port) + 1));
|
||||
|
||||
//
|
||||
// Identity: an explicit BT_SELF claims a specific seat; absent
|
||||
// (or "auto"), the RELAY assigns the next free roster seat --
|
||||
// players never need to know a player number.
|
||||
//
|
||||
const char *self_env = getenv("BT_SELF");
|
||||
if (self_env == NULL || self_env[0] == '\0')
|
||||
if (self_env == NULL || self_env[0] == '\0'
|
||||
|| _stricmp(self_env, "auto") == 0)
|
||||
{
|
||||
Fail("BT_RELAY requires BT_SELF=<your [pilots] entry>\n");
|
||||
if (!RelayRequestSeat())
|
||||
{
|
||||
Fail("relay seat request failed (roster full or relay "
|
||||
"unreachable)\n");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Str_Copy(relaySelf, self_env, sizeof(relaySelf));
|
||||
}
|
||||
Str_Copy(relaySelf, self_env, sizeof(relaySelf));
|
||||
relayMode = True;
|
||||
DEBUG_STREAM << "[relay] mode ON: relay "
|
||||
<< inet_ntoa(relayConsoleAddress.sin_addr) << ":"
|
||||
@@ -1335,6 +1351,84 @@ void L4NetworkManager::HostDisconnectedMessageHandler(HostDisconnectedMessage* H
|
||||
// D1 RELAY MODE support units. Everything below is dead unless BT_RELAY set.
|
||||
//#############################################################################
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// RelayRequestSeat -- SERVER-ASSIGNED SEATS: no BT_SELF means "give me any
|
||||
// free seat". A short throwaway connection to the relay game port sends
|
||||
// SEAT_REQUEST; the relay reserves the lowest unclaimed roster seat and
|
||||
// answers SEAT_ASSIGN {int32 hostID, NUL-terminated tag}. The tag becomes
|
||||
// relaySelf and everything downstream (egg self-match, HELLO) proceeds as if
|
||||
// BT_SELF had been set. SEAT_FULL / timeout => False.
|
||||
//
|
||||
Logical L4NetworkManager::RelayRequestSeat()
|
||||
{
|
||||
SOCKET seat_socket = RelayDialTcp(relayGameAddress, 60);
|
||||
if (seat_socket == INVALID_SOCKET)
|
||||
{
|
||||
return False;
|
||||
}
|
||||
Logical got_seat = False;
|
||||
RelayTcpEnvelope request;
|
||||
request.route = RELAY_ROUTE_SEAT_REQUEST;
|
||||
request.length = 0;
|
||||
if (RelaySendAll(seat_socket, (const char *)&request, sizeof(request)))
|
||||
{
|
||||
char reply[sizeof(RelayTcpEnvelope) + 80];
|
||||
int have = 0;
|
||||
unsigned long deadline = GetTickCount() + 10000UL;
|
||||
while (GetTickCount() < deadline && !got_seat)
|
||||
{
|
||||
fd_set read_set;
|
||||
FD_ZERO(&read_set);
|
||||
FD_SET(seat_socket, &read_set);
|
||||
timeval tv;
|
||||
tv.tv_sec = 0;
|
||||
tv.tv_usec = 200000;
|
||||
if (select(0, &read_set, NULL, NULL, &tv) != 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int received = recv(seat_socket, reply + have,
|
||||
(int)sizeof(reply) - have, 0);
|
||||
if (received <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
have += received;
|
||||
if (have < (int)sizeof(RelayTcpEnvelope))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
RelayTcpEnvelope *envelope = (RelayTcpEnvelope *)reply;
|
||||
if (envelope->route == RELAY_ROUTE_SEAT_FULL)
|
||||
{
|
||||
DEBUG_STREAM << "[relay] seat request: ROSTER FULL\n"
|
||||
<< std::flush;
|
||||
break;
|
||||
}
|
||||
if (envelope->route != RELAY_ROUTE_SEAT_ASSIGN)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (have < (int)(sizeof(RelayTcpEnvelope) + envelope->length))
|
||||
{
|
||||
continue; // partial payload; keep reading
|
||||
}
|
||||
const char *tag = reply + sizeof(RelayTcpEnvelope) + 4;
|
||||
int tag_max = (int)envelope->length - 4;
|
||||
if (tag_max > 0 && tag_max < (int)sizeof(relaySelf))
|
||||
{
|
||||
memcpy(relaySelf, tag, tag_max);
|
||||
relaySelf[tag_max] = '\0';
|
||||
DEBUG_STREAM << "[relay] seat ASSIGNED: '" << relaySelf
|
||||
<< "'\n" << std::flush;
|
||||
got_seat = True;
|
||||
}
|
||||
}
|
||||
}
|
||||
closesocket(seat_socket);
|
||||
return got_seat;
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// RelayDialTcp -- outbound TCP dial with a BOUNDED retry window (unlike
|
||||
// OpenConnection's unbounded 10061 busy-loop), then nonblocking + NODELAY.
|
||||
|
||||
@@ -338,6 +338,8 @@ private:
|
||||
//
|
||||
Logical
|
||||
RelayDiscover(SOCKADDR_IN *console_endpoint);
|
||||
Logical
|
||||
RelayRequestSeat();
|
||||
SOCKET
|
||||
RelayDialTcp(const SOCKADDR_IN &endpoint, int timeout_seconds);
|
||||
Logical
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
rem BT411 -- join 107.202.218.169's game (internet). Your seat/mech
|
||||
rem is assigned automatically by the operator console.
|
||||
set BT_RELAY=107.202.218.169:1500
|
||||
cd /d %~dp0content
|
||||
..\build\Release\btl4.exe -egg OPERATOR.EGG -net 1501
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
rem BT411 -- join the LAN game. Finds the operator
|
||||
rem console AND your seat automatically.
|
||||
set BT_RELAY=auto
|
||||
cd /d %~dp0content
|
||||
..\build\Release\btl4.exe -egg OPERATOR.EGG -net 1501
|
||||
@@ -184,6 +184,10 @@ ROUTE_HELLO = -2
|
||||
ROUTE_PEER_UP = -3
|
||||
ROUTE_PEER_DOWN = -4
|
||||
ROUTE_UDP_ACK = -5
|
||||
ROUTE_SEAT_REQUEST = -6 # client->relay: assign me a free roster seat
|
||||
ROUTE_SEAT_ASSIGN = -7 # relay->client: int32 hostID + NUL-terminated tag
|
||||
ROUTE_SEAT_FULL = -8 # relay->client: no free seats
|
||||
SEAT_RESERVE_SECONDS = 60.0
|
||||
HELLO_MAGIC = 0x31525442 # 'BTR1' little-endian
|
||||
FRAME_CAP = 1600 # NETWORKMANAGER_BUFFER_SIZE (NETWORK.h:89)
|
||||
FIRST_GAME_HOST_ID = 2 # FirstLegalHostID(1) == the console; pods 2..N+1
|
||||
@@ -257,6 +261,7 @@ class Relay:
|
||||
self.console_conns = []
|
||||
self.game_conns = [] # RelayGameConn (incl. unregistered)
|
||||
self.by_host = {} # hostID -> RelayGameConn
|
||||
self.seat_reservations = {} # hostID -> expiry (auto-seats)
|
||||
self.udp_endpoint = {} # hostID -> (addr, port)
|
||||
self.udp_sock = None
|
||||
self.launch_at = None
|
||||
@@ -472,6 +477,37 @@ class Relay:
|
||||
return
|
||||
|
||||
def _handle_game_frame(self, conn, route, payload):
|
||||
if conn.host_id is None and route == ROUTE_SEAT_REQUEST:
|
||||
# SERVER-ASSIGNED SEATS: hand out the lowest roster seat that is
|
||||
# neither claimed (by_host) nor reserved; reserve it briefly so
|
||||
# two simultaneous joiners can't race onto the same seat. The
|
||||
# requesting connection closes after the reply; the pod re-dials
|
||||
# and claims the seat with a normal HELLO.
|
||||
now = time.time()
|
||||
self.seat_reservations = {h: t for h, t in
|
||||
self.seat_reservations.items() if t > now}
|
||||
assigned = None
|
||||
for i, tag in enumerate(self.roster):
|
||||
host_id = FIRST_GAME_HOST_ID + i
|
||||
if host_id in self.by_host or host_id in self.seat_reservations:
|
||||
continue
|
||||
assigned = (host_id, tag)
|
||||
break
|
||||
if assigned is None:
|
||||
self._send_raw(conn, struct.pack(ENV_FMT_TCP,
|
||||
ROUTE_SEAT_FULL, 0))
|
||||
print(f"[relay] {conn.name()} seat request: ROSTER FULL",
|
||||
flush=True)
|
||||
return
|
||||
host_id, tag = assigned
|
||||
self.seat_reservations[host_id] = now + SEAT_RESERVE_SECONDS
|
||||
payload_out = struct.pack("<i", host_id) + tag.encode() + b"\0"
|
||||
self._send_raw(conn, struct.pack(ENV_FMT_TCP, ROUTE_SEAT_ASSIGN,
|
||||
len(payload_out)) + payload_out)
|
||||
print(f"[relay] {conn.name()} seat request -> assigned host "
|
||||
f"{host_id} '{tag}' (reserved {SEAT_RESERVE_SECONDS:.0f}s)",
|
||||
flush=True)
|
||||
return
|
||||
if conn.host_id is None:
|
||||
# First frame MUST be a valid HELLO.
|
||||
if route != ROUTE_HELLO or len(payload) < 8:
|
||||
@@ -491,6 +527,7 @@ class Relay:
|
||||
return
|
||||
conn.host_id = host_id
|
||||
self.by_host[host_id] = conn
|
||||
self.seat_reservations.pop(host_id, None) # claim clears reserve
|
||||
print(f"[relay] {conn.name()} REGISTERED "
|
||||
f"({len(self.by_host)}/{len(self.roster)})", flush=True)
|
||||
# PEER_UP exchange: newcomer learns everyone; everyone learns newcomer.
|
||||
|
||||
+24
-27
@@ -675,34 +675,31 @@ class Operator(QMainWindow):
|
||||
if not out_dir:
|
||||
return
|
||||
egg_name = os.path.basename(self.egg_path)
|
||||
for i, p in enumerate(self.egg.pilots()):
|
||||
# Internet variant: explicit public host.
|
||||
name = os.path.join(out_dir, "join_as_player%d.bat" % (i + 1))
|
||||
with open(name, "w", newline="\r\n") as f:
|
||||
f.write("@echo off\n"
|
||||
"rem BT411 -- join %s's game as player %d (%s)\n"
|
||||
"set BT_RELAY=%s:%d\n"
|
||||
"set BT_SELF=%s\n"
|
||||
"cd /d %%~dp0content\n"
|
||||
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n"
|
||||
% (host, i + 1, p.get("vehicle") or "?",
|
||||
host, self.f_port.value(), p["address"],
|
||||
egg_name))
|
||||
# LAN variant: zero-config auto-discovery (BT_RELAY=auto).
|
||||
lan = os.path.join(out_dir, "join_as_player%d_lan.bat" % (i + 1))
|
||||
with open(lan, "w", newline="\r\n") as f:
|
||||
f.write("@echo off\n"
|
||||
"rem BT411 -- join the LAN game as player %d (%s) --\n"
|
||||
"rem finds the operator console automatically.\n"
|
||||
"set BT_RELAY=auto\n"
|
||||
"set BT_SELF=%s\n"
|
||||
"cd /d %%~dp0content\n"
|
||||
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n"
|
||||
% (i + 1, p.get("vehicle") or "?", p["address"],
|
||||
egg_name))
|
||||
# ONE universal script per transport: the RELAY assigns seats
|
||||
# first-come-first-served (no BT_SELF), so every player gets the SAME
|
||||
# file -- who flies which mech is whoever sits down first, exactly
|
||||
# like walking up to a pod.
|
||||
with open(os.path.join(out_dir, "join.bat"), "w", newline="\r\n") as f:
|
||||
f.write("@echo off\n"
|
||||
"rem BT411 -- join %s's game (internet). Your seat/mech\n"
|
||||
"rem is assigned automatically by the operator console.\n"
|
||||
"set BT_RELAY=%s:%d\n"
|
||||
"cd /d %%~dp0content\n"
|
||||
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n"
|
||||
% (host, host, self.f_port.value(), egg_name))
|
||||
with open(os.path.join(out_dir, "join_lan.bat"), "w",
|
||||
newline="\r\n") as f:
|
||||
f.write("@echo off\n"
|
||||
"rem BT411 -- join the LAN game. Finds the operator\n"
|
||||
"rem console AND your seat automatically.\n"
|
||||
"set BT_RELAY=auto\n"
|
||||
"cd /d %%~dp0content\n"
|
||||
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n"
|
||||
% egg_name)
|
||||
self.log.appendPlainText(
|
||||
"exported %d player script pair(s) (internet + _lan) to %s "
|
||||
"(public host: %s)" % (len(self.egg.pilots()), out_dir, host))
|
||||
"exported join.bat + join_lan.bat to %s (public host: %s) -- "
|
||||
"one file for everyone; the relay assigns seats"
|
||||
% (out_dir, host))
|
||||
|
||||
# ------------------------------------------------------------- closing --
|
||||
|
||||
|
||||
Reference in New Issue
Block a user