From 47817ca6f5f06f1c173a7a830bf3302262837f76 Mon Sep 17 00:00:00 2001 From: Cyd Date: Tue, 11 Aug 2026 21:34:12 -0500 Subject: [PATCH] The setup page is the host's room Two player requests from the six-player night, one cause between them: the lobby treated hosting as somewhere you GO, and a role you could only have by being the machine that went there. Hosting no longer leaves the setup page. HOST STEAM GAME claims the lobby and stays put: the button becomes CLOSE LOBBY, joiners list under GAME LENGTH as they arrive (callsign, vehicle, a cam tag, and WRONG BUILD where launch would refuse - the reasons launch might say no, standing on the same screen as the launch button), and the track, weather and length stay changeable the whole time, republished to the members' rooms as they change. LAUNCH GAME launches the whole room from right there, and after the race the host lands back on the same page, lobby still open, roster still live. The room screen still exists - for members, whose flow is untouched. Mechanically: RPL4Lobby_Host's create-then-run-the-room split into HostOpen (create, publish, return) plus Pump/PublishSetup/RosterLines/ HostLaunch/Leave, with the owner-launch and leave blocks extracted from the room's message loop so both screens call the same code. The menu gets a timer that pumps Steam callbacks - nothing else pumps them with no room running - and republishes dirty picks at most every 1.5s, because Steam throttles chatty writers. One wrinkle: the lobby publishes through the RPL4FrontEnd_* accessors, which read globals the menu only wrote on the way OUT, so publishing from a live menu syncs them first or every publish would carry the previous visit's picks. And a member's Live Cam pick now works. It was host-only by design - the pick published as member data and the room displayed it, but the egg builder never read it, so a member who chose Live Cam raced their default loadout instead (the "default Quark - red" Cyd was listed as). The pick now rides the whole chain - lobby row, hosted-pilot table, egg entry as hostType=1 / vehicle=camera - and the member machine sets its own cockpit from its own pick at launch, before the renderers build, exactly as the host and playback already did. One rule holds it together: SOMEBODY has to race. If every pilot picks cam, every pick is stripped and the log says so - a grid of cameras has nothing to point at, and is also the shape known to hang the map load. The playtest launch failures while a member had cam picked are not directly explained - no logs were kept from those attempts - but the half-implemented state they ran in (flag published, egg ignoring it) is exactly the seam this closes, and the failure cannot recur in that form: the pick is now either honoured everywhere or stripped everywhere. Co-Authored-By: Claude Opus 5 (1M context) --- RP_L4/RPL4FE.cpp | 367 ++++++++++++++++++++++++++++++++++++++++++-- RP_L4/RPL4FE.h | 5 + RP_L4/RPL4LOBBY.cpp | 257 +++++++++++++++++++++++-------- RP_L4/RPL4LOBBY.h | 44 +++++- 4 files changed, 592 insertions(+), 81 deletions(-) diff --git a/RP_L4/RPL4FE.cpp b/RP_L4/RPL4FE.cpp index 1cccdb1..b00ffdf 100644 --- a/RP_L4/RPL4FE.cpp +++ b/RP_L4/RPL4FE.cpp @@ -305,7 +305,22 @@ namespace HBRUSH editBrush; Logical launched; Logical closed; - int steamAction; // 1 = host lobby, 2 = join lobby + int steamAction; // 2 = join lobby (hosting stays on this page) + + // + // Hosting without leaving this page. The lobby is claimed in + // place, the roster paints under GAME LENGTH, and LAUNCH GAME + // launches the room. Members still get the room screen; the + // host's room IS the setup menu. + // + Logical hostingLive; // we own an open lobby right now + Logical hostLaunch; // LAUNCH pressed while hosting: go as host + Logical publishDirty; // a pick changed; republish on the timer + DWORD lastPublish; // tick of the last republish (rate limit) + RECT rosterRect; // under GAME LENGTH, where joiners list + char roster[8][48]; + int rosterCount; + char hostNotice[64]; // why LAUNCH refused, drawn above it }; FEState *gFE = NULL; @@ -580,6 +595,7 @@ namespace char badge[24]; char team[32]; // football pick, "" = assign for them char position[16]; + Logical camera; // this member picked Live Cam }; // lobby-fed override (real personas + loadouts); empty = env parsing @@ -605,6 +621,7 @@ namespace gHostedPilots[i].badge[0] ? gHostedPilots[i].badge : "None"); strcpy(extras[i].team, gHostedPilots[i].team); strcpy(extras[i].position, gHostedPilots[i].position); + extras[i].camera = gHostedPilots[i].camera; } return gHostedPilotCount; } @@ -899,6 +916,16 @@ namespace } + // + // The lobby roster, under GAME LENGTH: whoever joins while this + // page is hosting is listed here, in the column whose bottom half + // was always empty. Display only - no clicks land in it. + // + fe->rosterRect.left = margin; + fe->rosterRect.right = margin + col_w; + fe->rosterRect.top = left_y + row_h / 2; + fe->rosterRect.bottom = client_h - 3 * row_h; + // launch button FEItem *launch = &fe->items[fe->itemCount++]; launch->group = GroupLaunch; @@ -1049,7 +1076,9 @@ namespace case GroupRole: return kRoles[index].name; case GroupRecord: return kRecording[index].name; case GroupLaunch: return "L A U N C H G A M E"; - case GroupSteamHost: return "HOST STEAM GAME"; + case GroupSteamHost: + return (gFE != NULL && gFE->hostingLive) + ? "CLOSE LOBBY" : "HOST STEAM GAME"; case GroupSteamJoin: return "JOIN STEAM GAME"; case GroupExit: return "EXIT GAME"; } @@ -1132,6 +1161,13 @@ namespace (item->group == GroupSteamHost || item->group == GroupSteamJoin); Logical dead = steam_button && !RPL4Lobby_Available(); + // one lobby at a time: no joining somebody else's while + // hosting our own + if (item->group == GroupSteamJoin && fe->hostingLive) + { + dead = True; + } + COLORREF ink = dead ? kGreenDim : kGreenBright; HBRUSH launch_brush = CreateSolidBrush(ink); FrameRect(mem, &row, launch_brush); @@ -1180,6 +1216,63 @@ namespace // (the pilot-name caption is drawn with the other labels above) + // + // The lobby roster, while this page is the host's room. Under + // GAME LENGTH, where the mission column runs out of settings. + // + if (fe->hostingLive) + { + int line_h = (fe->rosterRect.bottom - fe->rosterRect.top) / 10; + if (line_h < 16) line_h = 16; + + RECT line = fe->rosterRect; + line.bottom = line.top + line_h; + + char header[48]; + sprintf(header, "LOBBY OPEN - %d IN", + (fe->rosterCount > 0) ? fe->rosterCount : 1); + SetTextColor(mem, kGreenBright); + DrawTextA(mem, header, -1, &line, + DT_LEFT | DT_VCENTER | DT_SINGLELINE); + + for (int r = 0; r < fe->rosterCount; ++r) + { + line.top += line_h; + line.bottom += line_h; + if (line.bottom > fe->rosterRect.bottom) + { + break; + } + SetTextColor(mem, + (strstr(fe->roster[r], "WRONG BUILD") != NULL) + ? kGreenDim : kGreenBright); + DrawTextA(mem, fe->roster[r], -1, &line, + DT_LEFT | DT_VCENTER | DT_SINGLELINE); + } + } + + // + // Why LAUNCH refused, above the launch button - same slot the + // STEAM NOT RUNNING notice uses, same reasoning: it does not fit + // in the button. + // + if (fe->hostNotice[0] != '\0') + { + for (int i = 0; i < fe->itemCount; ++i) + { + if (fe->items[i].group == GroupLaunch) + { + RECT notice = fe->items[i].rect; + notice.bottom = notice.top; + notice.top -= (fe->items[i].rect.bottom - fe->items[i].rect.top) / 2; + SetTextColor(mem, kGreenDim); + DrawTextA(mem, fe->hostNotice, -1, ¬ice, + DT_CENTER | DT_VCENTER | DT_SINGLELINE); + break; + } + } + } + BitBlt(hdc, 0, 0, client.right, client.bottom, mem, 0, 0, SRCCOPY); SelectObject(mem, old_surface); @@ -1188,6 +1281,53 @@ namespace EndPaint(fe->menuWindow, &ps); } + enum { kLobbyTimerId = 7 }; + + // + // The lobby publishes through the RPL4FrontEnd_* accessors, and those + // read the persisted globals - which the menu writes only on the way + // out. Hosting from the menu publishes while the menu is still up, so + // the globals must be brought current first or every publish would + // send the PREVIOUS visit's picks. + // + void SyncPersistFromMenu(FEState *fe) + { + if (fe->nameEdit != NULL) + { + char name[24]; + name[0] = '\0'; + GetWindowTextA(fe->nameEdit, name, sizeof(name) - 1); + SanitizeCallsign(name, sizeof(name)); + if (name[0] != '\0') + { + strcpy(gLastPilotName, name); + } + } + memcpy(gPersistSelection, fe->selection, sizeof(gPersistSelection)); + gHavePersist = True; + } + + void RefreshRoster(FEState *fe) + { + char lines[8][48]; + int count = RPL4Lobby_RosterLines(lines, 8); + + Logical changed = (count != fe->rosterCount) ? True : False; + for (int i = 0; !changed && i < count; ++i) + { + if (strcmp(lines[i], fe->roster[i]) != 0) + { + changed = True; + } + } + if (changed) + { + memcpy(fe->roster, lines, sizeof(lines)); + fe->rosterCount = count; + InvalidateRect(fe->menuWindow, NULL, FALSE); + } + } + void MenuClick(FEState *fe, int x, int y) { for (int i = 0; i < fe->itemCount; ++i) @@ -1198,17 +1338,75 @@ namespace { if (item->group == GroupLaunch) { + // + // Hosting: LAUNCH launches the whole room, from right + // here. The go must carry the latest picks, so publish + // once more before it; a refusal names itself above + // the button rather than in a log nobody has open. + // + if (fe->hostingLive) + { + SyncPersistFromMenu(fe); + RPL4Lobby_PublishSetup(); + if (RPL4Lobby_HostLaunch()) + { + fe->hostNotice[0] = '\0'; + fe->hostLaunch = True; + fe->launched = True; + PostMessageA(fe->menuWindow, WM_NULL, 0, 0); + } + else + { + strcpy(fe->hostNotice, + "NOT EVERYONE IS READY - SEE THE ROSTER"); + InvalidateRect(fe->menuWindow, NULL, FALSE); + } + return; + } fe->launched = True; // wake the modal loop (a click delivered via // SendMessage never passes through the queue) PostMessageA(fe->menuWindow, WM_NULL, 0, 0); } - else if (item->group == GroupSteamHost || item->group == GroupSteamJoin) + else if (item->group == GroupSteamHost) { // dead until the Steam client is there; the button says so - if (RPL4Lobby_Available()) + if (!RPL4Lobby_Available()) { - fe->steamAction = (item->group == GroupSteamHost) ? 1 : 2; + return; + } + if (fe->hostingLive) + { + // CLOSE LOBBY: everyone's room tells them + RPL4Lobby_Leave(); + fe->hostingLive = False; + fe->rosterCount = 0; + fe->hostNotice[0] = '\0'; + KillTimer(fe->menuWindow, kLobbyTimerId); + InvalidateRect(fe->menuWindow, NULL, FALSE); + return; + } + // + // Claim the lobby and STAY: the page is the host's + // room now. The publish inside HostOpen reads the + // accessors, so sync first. + // + SyncPersistFromMenu(fe); + if (RPL4Lobby_HostOpen()) + { + fe->hostingLive = True; + fe->hostNotice[0] = '\0'; + SetTimer(fe->menuWindow, kLobbyTimerId, 750, NULL); + RefreshRoster(fe); + InvalidateRect(fe->menuWindow, NULL, FALSE); + } + } + else if (item->group == GroupSteamJoin) + { + // dead without Steam, and dead while hosting our own + if (RPL4Lobby_Available() && !fe->hostingLive) + { + fe->steamAction = 2; PostMessageA(fe->menuWindow, WM_NULL, 0, 0); } } @@ -1221,6 +1419,10 @@ namespace else { fe->selection[item->group] = item->index; + if (fe->hostingLive) + { + fe->publishDirty = True; // members' rooms track it + } if (item->group == GroupScenario) { // the scenario swaps the map list and the @@ -1275,6 +1477,10 @@ namespace if (pick >= 0) { fe->selection[group] = pick; + if (fe->hostingLive) + { + fe->publishDirty = True; + } } // the closed box is ours to redraw now InvalidateRect(fe->combo[group], NULL, FALSE); @@ -1282,6 +1488,36 @@ namespace return 0; } } + // a callsign being typed reaches the members' rooms too + if (fe != NULL && HIWORD(wParam) == EN_CHANGE && fe->hostingLive) + { + fe->publishDirty = True; + } + break; + + // + // The lobby heartbeat, while this page is the host's room. Steam + // only delivers member joins, leaves and data through its + // callback pump, and with no room screen running nothing else + // pumps it. Publishes ride the same beat, rate-limited, so a + // burst of clicking costs one SetLobbyData volley, not ten - + // Steam throttles writers who cannot restrain themselves. + // + case WM_TIMER: + if (fe != NULL && wParam == kLobbyTimerId && fe->hostingLive) + { + RPL4Lobby_Pump(); + RefreshRoster(fe); + if (fe->publishDirty && + (GetTickCount() - fe->lastPublish) > 1500) + { + fe->publishDirty = False; + fe->lastPublish = GetTickCount(); + SyncPersistFromMenu(fe); + RPL4Lobby_PublishSetup(); + } + return 0; + } break; // @@ -1461,6 +1697,7 @@ namespace const char *vehicle; const char *color; const char *badge; + Logical camera; // rides as hostType=1 / vehicle=camera int team; // kTeams index, -1 outside football const char *position; Logical chosePosition; // picked it themselves @@ -1474,6 +1711,7 @@ namespace owner->vehicle = kVehicles[fe->selection[GroupVehicle]].key; owner->color = kColors[fe->selection[GroupColor]].key; owner->badge = kBadges[fe->selection[GroupBadge]].key; + owner->camera = False; // resolved below with everyone else's owner->team = -1; owner->position = NULL; owner->chosePosition = False; @@ -1485,11 +1723,50 @@ namespace pilot->vehicle = extras[p].vehicle; pilot->color = extras[p].color; pilot->badge = extras[p].badge; + // + // A member's Live Cam pick rides its lobby row. Race only: + // football normalizes everyone into a team with a runner, + // and a camera has no place on either side of that. + // + pilot->camera = (!football && extras[p].camera) ? True : False; pilot->team = -1; pilot->position = NULL; pilot->chosePosition = False; } + // + // The owner's own pick, and then the rule that makes any set of + // picks launchable: SOMEBODY has to race. A camera needs pods to + // point at - a grid of nothing but cameras is also the shape + // known to hang the map load - so if honouring every pick would + // leave no racer, every pick is stripped and the log says so. + // The lobby roster shows who picked cam, so this is visible + // before launch, not a surprise after it. + // + owner->camera = (WantsCamera(fe->selection) && extra_count > 0) + ? True : False; + { + int racer_count = 0; + + for (int p = 0; p < pilot_count; ++p) + { + if (!pilots[p].camera) + { + ++racer_count; + } + } + if (racer_count == 0) + { + for (int p = 0; p < pilot_count; ++p) + { + pilots[p].camera = False; + } + DEBUG_STREAM << "FE: every pilot picked Live Cam - somebody" + << " has to race, so nobody is a camera this time\n" + << std::flush; + } + } + // // Football: every pilot's own team/position pick is honored; // unset picks get filled in, then each team is normalized to @@ -1659,7 +1936,12 @@ namespace // else on the line stays - dropzone and colour cost nothing and // the egg readers still expect them. // - Logical owner_is_camera = WantsCamera(fe->selection) && extra_count > 0; + // + // The owner's camera pick was resolved into pilots[0] with + // everyone else's, up where the table was built - including the + // somebody-must-race strip. This is what remains true of it here. + // + Logical owner_is_camera = pilots[0].camera; if (WantsCamera(fe->selection) && extra_count == 0) { DEBUG_STREAM << "FE: Live Cam ignored - no other pods in this race\n" @@ -1696,7 +1978,13 @@ namespace for (int p = 0; p < pilot_count; ++p) { - Logical camera_entry = (p == 0 && owner_is_camera); + // + // Any pilot's camera pick now, not just the owner's - a + // member who chose Live Cam in its own menu rides as + // hostType=1 / vehicle=camera, and its machine sets its own + // cockpit up from the same pick at launch. + // + Logical camera_entry = pilots[p].camera; sprintf(line, "[%s]\n", pilots[p].address); egg += line; @@ -1882,10 +2170,13 @@ Logical EnsurePilotSettingsLoaded(); //--------------------------------------------------------------- - // Coming back from a race while still in a lobby: straight to - // the room (the lobby outlives races - single binary payoff) + // Coming back from a race while still in a lobby: members go + // straight to the room (the lobby outlives races - single binary + // payoff). The OWNER falls through to the setup menu instead - + // that page is the host's room now, hosting still live, so the + // next race is configured right where the roster shows. //--------------------------------------------------------------- - if (RPL4Lobby_InRoom()) + if (RPL4Lobby_InRoom() && !RPL4Lobby_IsOwner()) { int outcome = RPL4Lobby_Room(instance, main_window); if (outcome == LobbyRoomClosed) @@ -1894,6 +2185,17 @@ Logical } if (outcome == LobbyLaunchMember) { + // + // A member's cockpit comes from its OWN role pick, set here + // because the renderers are built before the wire egg + // arrives - the same ordering that decides the host's and + // playback's cockpits. The host honours the same pick in the + // egg, so the two agree; the one exception is the + // everybody-picked-cam room, where the host strips every + // pick to keep a racer and this machine cannot know yet. + // + Application::SetCameraStation( + RPL4FrontEnd_IsCameraRole() ? True : False); gLastLaunchMode = FELaunchMember; egg_path_out[0] = '\0'; return True; @@ -1983,6 +2285,18 @@ Logical SendMessageA(fe.nameEdit, WM_SETFONT, (WPARAM) fe.textFont, TRUE); SendMessageA(fe.nameEdit, EM_SETLIMITTEXT, sizeof(fe.pilotName) - 2, 0); + // + // Back from a race we hosted: the lobby is still open and this + // page picks hosting straight back up - roster, heartbeat, all + // of it - so the host never sees the room screen at all. + // + if (RPL4Lobby_IsOwner()) + { + fe.hostingLive = True; + SetTimer(fe.menuWindow, kLobbyTimerId, 750, NULL); + RefreshRoster(&fe); + } + LayoutMenu(&fe, client.right, client.bottom); InvalidateRect(fe.menuWindow, NULL, TRUE); @@ -2055,7 +2369,7 @@ Logical Logical launched = fe.launched; Logical closed = fe.closed; - int steam_action = fe.steamAction; + Logical host_launch = fe.hostLaunch; DestroyWindow(fe.menuWindow); DeleteObject(fe.textFont); @@ -2065,27 +2379,46 @@ Logical if (closed) { + // + // Quitting while hosting closes the lobby too - members see + // the room fold rather than a host who never comes back. + // + if (fe.hostingLive) + { + RPL4Lobby_Leave(); + } return False; } if (launched) { - // plain launch: single player, or a LAN-hosted race when - // RP412HOSTPODS is set in the environment + // + // LAUNCH while hosting goes out as the lobby's host - the + // roster is primed and the go is published. Otherwise it is + // the plain launch: single player, or a LAN-hosted race when + // RP412HOSTPODS is set in the environment. + // + if (host_launch) + { + gLastLaunchMode = FELaunchHost; + } return BuildEggFromPersisted(egg_path_out, egg_path_size); } //--------------------------------------------------------------- - // Steam lobby (host or join), then back here if they leave + // Steam lobby join, then back here if they leave. (Hosting no + // longer exits the menu - the page IS the host's room.) //--------------------------------------------------------------- - int outcome = (steam_action == 1) - ? RPL4Lobby_Host(instance, main_window) - : RPL4Lobby_Join(instance, main_window); + int outcome = RPL4Lobby_Join(instance, main_window); if (outcome == LobbyRoomClosed) { return False; } if (outcome == LobbyLaunchMember) { + // same as the room re-entry path: the cockpit is this + // machine's own role pick, set before the renderers build + Application::SetCameraStation( + RPL4FrontEnd_IsCameraRole() ? True : False); gLastLaunchMode = FELaunchMember; egg_path_out[0] = '\0'; return True; diff --git a/RP_L4/RPL4FE.h b/RP_L4/RPL4FE.h index 11c50c1..9f8d50c 100644 --- a/RP_L4/RPL4FE.h +++ b/RP_L4/RPL4FE.h @@ -142,6 +142,11 @@ struct FEHostedPilot // football: the member's own picks, empty = assign one for them char team[32]; // team key ("Red/Pink", ...) char position[16]; // "runner" / "crusher" / "blocker" + + // the member picked Live Cam: hostType=1, vehicle=camera in the egg. + // Somebody still has to race - the egg builder strips every cam pick + // if honouring them would leave nothing to point a camera at. + Logical camera; }; void RPL4FrontEnd_SetHostedPilots( diff --git a/RP_L4/RPL4LOBBY.cpp b/RP_L4/RPL4LOBBY.cpp index 2c2d98a..fd03b60 100644 --- a/RP_L4/RPL4LOBBY.cpp +++ b/RP_L4/RPL4LOBBY.cpp @@ -13,7 +13,13 @@ Logical RPL4Lobby_Available() { return False; } Logical RPL4Lobby_Configured() { return False; } Logical RPL4Lobby_InRoom() { return False; } -int RPL4Lobby_Host(HINSTANCE, HWND) { return LobbyRoomLeft; } +Logical RPL4Lobby_HostOpen() { return False; } +Logical RPL4Lobby_IsOwner() { return False; } +void RPL4Lobby_Pump() { } +void RPL4Lobby_PublishSetup() { } +int RPL4Lobby_RosterLines(char [][48], int) { return 0; } +Logical RPL4Lobby_HostLaunch() { return False; } +void RPL4Lobby_Leave() { } int RPL4Lobby_Join(HINSTANCE, HWND) { return LobbyRoomLeft; } int RPL4Lobby_Room(HINSTANCE, HWND) { return LobbyRoomLeft; } void RPL4Lobby_PushRaceResults() { } @@ -398,6 +404,7 @@ namespace strncpy(pilot->badge, members[i].badge, sizeof(pilot->badge) - 1); strncpy(pilot->team, members[i].team, sizeof(pilot->team) - 1); strncpy(pilot->position, members[i].position, sizeof(pilot->position) - 1); + pilot->camera = members[i].camera; if (pods[0] != '\0') { @@ -418,6 +425,94 @@ namespace putenv(port_env); } + //--------------------------------------------------------------- + // Leaving and launching, callable from either screen. + // + // These began as blocks inside the room's message loop, which meant + // only the room could do either - and the host no longer lives in + // the room: hosting is claimed from the setup menu, where the track + // is still changeable and the roster shows under GAME LENGTH. The + // room keeps calling them; the menu calls them too. + //--------------------------------------------------------------- + + void LeaveLobbyNow() + { + if (!gInLobby) + { + return; + } + SteamMatchmaking()->LeaveLobby(gLobby); + gInLobby = False; + // plain menu launches must not inherit lobby hosting + RPL4FrontEnd_SetHostedPilots(NULL, NULL, 0); + static char clear_env[] = "RP412HOSTPODS="; + putenv(clear_env); + } + + // + // The owner's go: verify the room, publish the roster, prime the + // hosted-race path. Answers False when somebody has not published or + // is the wrong build - the caller says so however it says things. + // + Logical OwnerLaunchNow() + { + MemberInfo members[kMaxLobbyMembers]; + int member_count = CollectMembers(members); + Logical all_published = True; + Logical all_same_build = True; + + for (int i = 0; i < member_count; ++i) + { + if (!members[i].published) + { + all_published = False; + } + if (strcmp(members[i].netRev, kNetRevision) != 0) + { + all_same_build = False; + DEBUG_STREAM << "Lobby: " << members[i].name + << " simulates like rev '" << members[i].netRev + << "', we are rev '" << kNetRevision << "'\n" << std::flush; + } + // + // The exact build too. An empty string is an older build that + // predates the key and cannot be trusted to match either. + // + if (strcmp(members[i].build, RP412_VERSION) != 0) + { + all_same_build = False; + DEBUG_STREAM << "Lobby: " << members[i].name + << " is build '" + << (members[i].build[0] ? members[i].build : "(older)") + << "', we are '" << RP412_VERSION << "'\n" << std::flush; + } + } + if (!all_published || !all_same_build || member_count < 1) + { + DEBUG_STREAM << "Lobby: not everyone is ready yet\n" << std::flush; + return False; + } + + ++gLastGoNonce; + char go[800]; + sprintf(go, "%d:", gLastGoNonce); + for (int i = 0; i < member_count; ++i) + { + char entry[96]; + sprintf(entry, "%s|%d|%d|%I64u;", members[i].ip, + members[i].consolePort, members[i].gamePort, + members[i].id.ConvertToUint64()); + if (strlen(go) + strlen(entry) < sizeof(go)) + { + strcat(go, entry); + } + } + SteamMatchmaking()->SetLobbyData(gLobby, kGoKey, go); + RegisterRoster(members, member_count); + PrimeHostedRace(members, member_count); + return True; + } + //--------------------------------------------------------------- // The room screen (front-end style: green on black) //--------------------------------------------------------------- @@ -890,12 +985,7 @@ namespace if (room.leaveClicked) { - SteamMatchmaking()->LeaveLobby(gLobby); - gInLobby = False; - // plain menu launches must not inherit lobby hosting - RPL4FrontEnd_SetHostedPilots(NULL, NULL, 0); - static char clear_env[] = "RP412HOSTPODS="; - putenv(clear_env); + LeaveLobbyNow(); outcome = LobbyRoomLeft; break; } @@ -906,59 +996,11 @@ namespace if (room.launchClicked) { room.launchClicked = False; - room.memberCount = CollectMembers(room.members); - Logical all_published = True; - Logical all_same_build = True; - for (int i = 0; i < room.memberCount; ++i) + if (OwnerLaunchNow()) { - if (!room.members[i].published) - { - all_published = False; - } - if (strcmp(room.members[i].netRev, kNetRevision) != 0) - { - all_same_build = False; - DEBUG_STREAM << "Lobby: " << room.members[i].name - << " simulates like rev '" << room.members[i].netRev - << "', we are rev '" << kNetRevision << "'\n" << std::flush; - } - // - // The exact build too. An empty string is an older - // build that predates the key and cannot be trusted to - // match either. - // - if (strcmp(room.members[i].build, RP412_VERSION) != 0) - { - all_same_build = False; - DEBUG_STREAM << "Lobby: " << room.members[i].name - << " is build '" - << (room.members[i].build[0] ? room.members[i].build : "(older)") - << "', we are '" << RP412_VERSION << "'\n" << std::flush; - } - } - if (all_published && all_same_build && room.memberCount >= 1) - { - ++gLastGoNonce; - char go[800]; - sprintf(go, "%d:", gLastGoNonce); - for (int i = 0; i < room.memberCount; ++i) - { - char entry[96]; - sprintf(entry, "%s|%d|%d|%I64u;", room.members[i].ip, - room.members[i].consolePort, room.members[i].gamePort, - room.members[i].id.ConvertToUint64()); - if (strlen(go) + strlen(entry) < sizeof(go)) - { - strcat(go, entry); - } - } - SteamMatchmaking()->SetLobbyData(gLobby, kGoKey, go); - RegisterRoster(room.members, room.memberCount); - PrimeHostedRace(room.members, room.memberCount); outcome = LobbyLaunchHost; break; } - DEBUG_STREAM << "Lobby: not everyone is ready yet\n" << std::flush; } // @@ -1096,11 +1138,11 @@ Logical // Available(), but a dead DLL must not depend on the UI for safety. // int - RPL4Lobby_Host(HINSTANCE instance, HWND main_window) + RPL4Lobby_HostOpen() { - if (!SteamNetTransport_ClientLibraryPresent()) + if (!SteamNetTransport_ClientLibraryPresent() || gInLobby) { - return LobbyRoomLeft; + return gInLobby; } gCallDone = False; @@ -1110,15 +1152,106 @@ int if (!WaitForCall(15000)) { DEBUG_STREAM << "Lobby: CreateLobby failed\n" << std::flush; - return LobbyRoomLeft; + return False; } gLobby = gCallLobby; gInLobby = True; gLastGoNonce = 0; SteamMatchmaking()->SetLobbyData(gLobby, kLobbyTagKey, "1"); SteamMatchmaking()->SetLobbyData(gLobby, kGoKey, ""); - DEBUG_STREAM << "Lobby: hosting " << gLobby.ConvertToUint64() << "\n" << std::flush; - return RunRoom(instance, main_window); + + // + // The owner's row and the mission setup, published immediately: a + // joiner can arrive seconds from now, and what they see first is + // whatever is on the books. + // + PublishMemberData(); + DEBUG_STREAM << "Lobby: hosting " << gLobby.ConvertToUint64() + << " from the setup menu\n" << std::flush; + return True; +} + +Logical + RPL4Lobby_IsOwner() +{ + return gInLobby && IsOwner(); +} + +void + RPL4Lobby_Pump() +{ + if (SteamNetTransport_ClientLibraryPresent()) + { + SteamAPI_RunCallbacks(); + } +} + +void + RPL4Lobby_PublishSetup() +{ + if (gInLobby) + { + PublishMemberData(); + } +} + +// +// The roster, as display lines for the setup menu: callsign plus what it +// is bringing, a cam tag for a member who picked Live Cam, and a build +// warning where launch would refuse - so the host can see WHY the room is +// not ready, standing in the same screen the launch button is on. +// +int + RPL4Lobby_RosterLines(char lines[][48], int max_lines) +{ + if (!gInLobby || max_lines <= 0) + { + return 0; + } + + MemberInfo members[kMaxLobbyMembers]; + int count = CollectMembers(members); + int written = 0; + + for (int i = 0; i < count && written < max_lines; ++i) + { + const char *name = members[i].name[0] ? members[i].name : "(joining...)"; + const char *note = ""; + + if (strcmp(members[i].build, RP412_VERSION) != 0) + { + note = " WRONG BUILD"; + } + else if (members[i].camera) + { + note = " cam"; + } + else if (!members[i].published) + { + note = " ..."; + } + _snprintf(lines[written], 47, "%-14s %s%s", + name, members[i].vehicle, note); + lines[written][47] = '\0'; + ++written; + } + return written; +} + +Logical + RPL4Lobby_HostLaunch() +{ + if (!gInLobby || !IsOwner()) + { + return False; + } + return OwnerLaunchNow(); +} + +void + RPL4Lobby_Leave() +{ + LeaveLobbyNow(); } int diff --git a/RP_L4/RPL4LOBBY.h b/RP_L4/RPL4LOBBY.h index aed69f3..cca2129 100644 --- a/RP_L4/RPL4LOBBY.h +++ b/RP_L4/RPL4LOBBY.h @@ -46,9 +46,49 @@ Logical Logical RPL4Lobby_InRoom(); -// Create a lobby / find-and-join one, then run the room screen. +//------------------------------------------------------------------------ +// Hosting, from the setup menu. +// +// The host never leaves the configuration page: HostOpen claims the lobby +// and returns immediately, the menu keeps showing (and changing) the +// mission, joiners appear under GAME LENGTH via RosterLines, and LAUNCH +// GAME goes through HostLaunch. The room screen is for members only. +//------------------------------------------------------------------------ + +// Create the lobby and publish our row + the mission setup. True on +// success (also true if already in one). Does not open any window. +Logical + RPL4Lobby_HostOpen(); + +// True when we are in a lobby we own. +Logical + RPL4Lobby_IsOwner(); + +// Run the Steam callbacks once - the menu's timer calls this so member +// joins/leaves and their data show up while no room screen is pumping. +void + RPL4Lobby_Pump(); + +// Republish our member row and (as owner) the mission setup. Call after +// the host changes anything on the menu, so members' rooms track it. +void + RPL4Lobby_PublishSetup(); + +// The roster as display lines ("callsign vehicle [cam|WRONG BUILD]"). +// Returns the number written, 0 when not in a lobby. int - RPL4Lobby_Host(HINSTANCE instance, HWND main_window); + RPL4Lobby_RosterLines(char lines[][48], int max_lines); + +// The owner's go: verify everyone, publish the roster, prime the hosted +// race. False (and a log line saying who) when the room is not ready. +Logical + RPL4Lobby_HostLaunch(); + +// Leave the lobby and clear the hosted-race environment. +void + RPL4Lobby_Leave(); + +// Find-and-join a lobby, then run the room screen (members). int RPL4Lobby_Join(HINSTANCE instance, HWND main_window);