From the playtest: a member who Alt+Q's mid-race vanishes from the host's
score sheet, as though they had not raced at all. A pod that leaves never
sends its EndMission score - that message belongs to the buzzer - and the
sheet only ever held what EndMission delivered.
The pods have been telling the console their score all along:
ConsolePlayerVTVScoreUpdate telemetry fed the arcade console's status
board, and this console skipped it ('IDs 2-6 skip through for now'). It
now keeps the last score each pod reported, together with the host ID the
pod itself put in the message - no arithmetic about egg order to get
wrong - and at the buzzer any pod that is not there to answer has its
last telemetry score stood in and named in the log. Leaving early keeps
what was earned; it just stops earning.
Also on the leaver's own machine: RPL4Lobby_PullRaceResults gave every
member eight seconds of frozen window waiting for a score sheet - but an
aborter leaves a race still running everywhere else, whose sheet will not
exist for minutes. Exit_Code carries the abort at that moment (the race
loop resets it after the menu), so the wait becomes one free look. The
sheet's nonce keeps it eligible to show once the race really ends.
The member's drop-to-desktop after Alt+Q is NOT fixed here - it is not
yet diagnosed, and this session has spent enough confident guesses. The
next piece of evidence that names it is the member machine's rpl4.log
tail from right after an abort.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1675 lines
50 KiB
C++
1675 lines
50 KiB
C++
#include "..\munga_l4\mungal4.h"
|
|
#pragma hdrstop
|
|
|
|
#include "rpl4lobby.h"
|
|
|
|
#ifndef RP412_STEAM
|
|
|
|
//########################################################################
|
|
// No Steam SDK in this build: the lobby is unavailable, everything
|
|
// else (single player, LAN -net races) works as always.
|
|
//########################################################################
|
|
|
|
Logical RPL4Lobby_Available() { return False; }
|
|
Logical RPL4Lobby_Configured() { return False; }
|
|
Logical RPL4Lobby_InRoom() { return False; }
|
|
int RPL4Lobby_HostOpen() { return HostOpenFailed; }
|
|
int RPL4Lobby_JoinOpen(HWND) { return JoinOpenNothingFound; }
|
|
int RPL4Lobby_MemberPoll() { return MemberPollClosed; }
|
|
void RPL4Lobby_GetSetup(char *a, char *b, char *c, char *d, char *e)
|
|
{ a[0] = b[0] = c[0] = d[0] = e[0] = '\0'; }
|
|
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() { }
|
|
void RPL4Lobby_PullRaceResults() { }
|
|
|
|
#else
|
|
|
|
#include "rpl4fe.h"
|
|
#include "rpl4console.h"
|
|
#include "rpl4build.h" // generated: RP412_VERSION, for the build guard
|
|
#include "..\munga_l4\l4steamtransport.h"
|
|
|
|
#pragma pack(push, 8)
|
|
#include "steam/steam_api.h"
|
|
#pragma pack(pop)
|
|
|
|
#include <stdio.h>
|
|
|
|
//########################################################################
|
|
// Lobby state - one lobby at a time, alive across races
|
|
//########################################################################
|
|
|
|
namespace
|
|
{
|
|
// A full grid is eight pods, as the pod hall ran it. The egg builder
|
|
// carries the owner plus maxExtraPilots (8), so eight members fit
|
|
// with room to spare.
|
|
enum { kMaxLobbyMembers = 8 };
|
|
const char kLobbyTagKey[] = "rp412";
|
|
const char kGoKey[] = "go";
|
|
|
|
CSteamID gLobby;
|
|
Logical gInLobby = False;
|
|
int gLastGoNonce = 0; // launches we already answered
|
|
int gShownResultsNonce = 0; // score sheets we already displayed
|
|
const char kResultsKey[] = "res";
|
|
const char kScenarioKey[] = "sc";
|
|
|
|
//-------------------------------------------------------------------
|
|
// Simulation protocol revision. Bump this whenever a change makes
|
|
// two builds simulate the same mission differently - it is not the
|
|
// wire format alone. Map entity ownership is dealt by advancing a
|
|
// shared cursor once per map entity, so anything that changes which
|
|
// entities are dealt at all silently desynchronizes who owns what.
|
|
//
|
|
// 2 - doorframes became local Hermit clockwork and are no longer
|
|
// dealt, which shifts every subsequent map entity's owner
|
|
// 1 - the 3-machine verified Steam build
|
|
//-------------------------------------------------------------------
|
|
const char kNetRevision[] = "2";
|
|
const char kNetRevKey[] = "nr";
|
|
|
|
// this member picked Live Cam rather than a grid slot
|
|
const char kCamKey[] = "cam";
|
|
|
|
//
|
|
// The exact build, so a room cannot mix them.
|
|
//
|
|
// kNetRevision above is hand-maintained and only bumped when someone
|
|
// decides a change alters the simulation - which means two DIFFERENT
|
|
// builds normally carry the same revision and will happily race each
|
|
// other. That is fine when the difference really is cosmetic and
|
|
// disastrous when the judgement was wrong, and it is not a judgement
|
|
// anyone should have to make correctly every time. The patch number is
|
|
// the repository's commit count, so this compares the actual binary.
|
|
//
|
|
// Both guards stay: the revision still refuses a mix that is known to
|
|
// simulate differently even between builds that agree here, which
|
|
// matters for anyone hand-editing a version.
|
|
//
|
|
const char kBuildKey[] = "bld";
|
|
|
|
// the owner's mission setup, shown to everyone in the room
|
|
const char kMapKey[] = "mp";
|
|
const char kTimeKey[] = "td";
|
|
const char kWeatherKey[] = "wx";
|
|
const char kLengthKey[] = "ln";
|
|
|
|
// async call-result plumbing
|
|
Logical gCallDone = False;
|
|
Logical gCallOK = False;
|
|
CSteamID gCallLobby;
|
|
|
|
class LobbyCalls
|
|
{
|
|
public:
|
|
CCallResult<LobbyCalls, LobbyCreated_t> createResult;
|
|
CCallResult<LobbyCalls, LobbyMatchList_t> listResult;
|
|
CCallResult<LobbyCalls, LobbyEnter_t> enterResult;
|
|
|
|
void OnCreated(LobbyCreated_t *result, bool io_failure)
|
|
{
|
|
gCallOK = !io_failure && result->m_eResult == k_EResultOK;
|
|
gCallLobby = CSteamID(result->m_ulSteamIDLobby);
|
|
gCallDone = True;
|
|
}
|
|
void OnList(LobbyMatchList_t *result, bool io_failure)
|
|
{
|
|
gCallOK = !io_failure && result->m_nLobbiesMatching > 0;
|
|
if (gCallOK)
|
|
{
|
|
gCallLobby = SteamMatchmaking()->GetLobbyByIndex(0);
|
|
}
|
|
gCallDone = True;
|
|
}
|
|
void OnEnter(LobbyEnter_t *result, bool io_failure)
|
|
{
|
|
gCallOK = !io_failure &&
|
|
result->m_EChatRoomEnterResponse == k_EChatRoomEnterResponseSuccess;
|
|
gCallLobby = CSteamID(result->m_ulSteamIDLobby);
|
|
gCallDone = True;
|
|
}
|
|
};
|
|
LobbyCalls gCalls;
|
|
|
|
Logical WaitForCall(int timeout_ms)
|
|
{
|
|
DWORD deadline = GetTickCount() + timeout_ms;
|
|
while (!gCallDone)
|
|
{
|
|
SteamAPI_RunCallbacks();
|
|
if ((LONG)(GetTickCount() - deadline) >= 0)
|
|
{
|
|
return False;
|
|
}
|
|
Sleep(50);
|
|
}
|
|
return gCallOK;
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Member data: what every pod publishes on entering the room
|
|
//---------------------------------------------------------------
|
|
void SanitizeName(const char *in, char *out, int out_size)
|
|
{
|
|
int n = 0;
|
|
for (int i = 0; in[i] != '\0' && n < out_size - 1; ++i)
|
|
{
|
|
char c = in[i];
|
|
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
|
(c >= '0' && c <= '9') || c == ' ' || c == '-' || c == '_')
|
|
{
|
|
out[n++] = c;
|
|
}
|
|
}
|
|
out[n] = '\0';
|
|
if (n == 0)
|
|
{
|
|
strcpy(out, "Pilot");
|
|
}
|
|
}
|
|
|
|
Logical IsOwner()
|
|
{
|
|
return gInLobby &&
|
|
SteamMatchmaking()->GetLobbyOwner(gLobby) == SteamUser()->GetSteamID();
|
|
}
|
|
|
|
void PublishMemberData()
|
|
{
|
|
char value[64];
|
|
|
|
SteamMatchmaking()->SetLobbyMemberData(gLobby, "ip",
|
|
SteamNetTransport_GetFakeAddressString());
|
|
sprintf(value, "%d", SteamNetTransport_GetFakeConsolePort());
|
|
SteamMatchmaking()->SetLobbyMemberData(gLobby, "cp", value);
|
|
sprintf(value, "%d", SteamNetTransport_GetFakeGamePort());
|
|
SteamMatchmaking()->SetLobbyMemberData(gLobby, "gp", value);
|
|
|
|
//
|
|
// The name in the room is the CALLSIGN from the setup screen, not
|
|
// the Steam persona. Publishing the persona meant the box a player
|
|
// types their name into had no effect on anything they could see -
|
|
// and because it never came from a file, wiping the install
|
|
// directory did not shake it loose either.
|
|
//
|
|
// The persona is kept as the fallback for somebody who has never
|
|
// set a callsign: better to appear as yourself than as "Pilot".
|
|
//
|
|
char name[32];
|
|
const char *callsign = RPL4FrontEnd_Callsign();
|
|
if (callsign != NULL && callsign[0] != '\0' &&
|
|
strcmp(callsign, "Pilot") != 0)
|
|
{
|
|
SanitizeName(callsign, name, sizeof(name));
|
|
}
|
|
else
|
|
{
|
|
SanitizeName(SteamFriends()->GetPersonaName(), name, sizeof(name));
|
|
}
|
|
SteamMatchmaking()->SetLobbyMemberData(gLobby, "nm", name);
|
|
|
|
char vehicle[24], color[16], badge[24];
|
|
RPL4FrontEnd_GetLoadout(vehicle, color, badge);
|
|
SteamMatchmaking()->SetLobbyMemberData(gLobby, "vh", vehicle);
|
|
SteamMatchmaking()->SetLobbyMemberData(gLobby, "cl", color);
|
|
SteamMatchmaking()->SetLobbyMemberData(gLobby, "bd", badge);
|
|
|
|
//
|
|
// Live Cam. The loadout above still goes out unchanged - the pick
|
|
// is a role, not a vehicle, and it is only acted on for the host,
|
|
// so a member's own row must go on showing what it will really
|
|
// fly. The room screen reads this against the owner's row.
|
|
//
|
|
SteamMatchmaking()->SetLobbyMemberData(gLobby, kCamKey,
|
|
RPL4FrontEnd_IsCameraRole() ? "1" : "0");
|
|
|
|
// football: this member's own team and position pick
|
|
SteamMatchmaking()->SetLobbyMemberData(gLobby, "tm",
|
|
RPL4FrontEnd_TeamKey(RPL4FrontEnd_GetTeamIndex()));
|
|
SteamMatchmaking()->SetLobbyMemberData(gLobby, "ps",
|
|
RPL4FrontEnd_PositionKey(RPL4FrontEnd_GetPositionIndex()));
|
|
|
|
// what this build simulates like, so a mismatched room cannot launch
|
|
SteamMatchmaking()->SetLobbyMemberData(gLobby, kNetRevKey, kNetRevision);
|
|
|
|
// and which build it actually IS - see kBuildKey
|
|
SteamMatchmaking()->SetLobbyMemberData(gLobby, kBuildKey, RP412_VERSION);
|
|
|
|
//---------------------------------------------------------------
|
|
// Only the owner's menu decides the mission, so the owner also
|
|
// publishes what it picked: the scenario (members need it to know
|
|
// whether their team/position pick matters) and the setup
|
|
// everyone is about to fly into.
|
|
//---------------------------------------------------------------
|
|
if (IsOwner())
|
|
{
|
|
// members check these before they act on the owner's go, and
|
|
// a joiner checks the build before it even sits down
|
|
SteamMatchmaking()->SetLobbyData(gLobby, kNetRevKey, kNetRevision);
|
|
SteamMatchmaking()->SetLobbyData(gLobby, kBuildKey, RP412_VERSION);
|
|
SteamMatchmaking()->SetLobbyData(gLobby, kScenarioKey,
|
|
RPL4FrontEnd_IsFootballSelected() ? "football" : "race");
|
|
SteamMatchmaking()->SetLobbyData(gLobby, kMapKey,
|
|
RPL4FrontEnd_SelectedMapName());
|
|
SteamMatchmaking()->SetLobbyData(gLobby, kTimeKey,
|
|
RPL4FrontEnd_SelectedTimeName());
|
|
SteamMatchmaking()->SetLobbyData(gLobby, kWeatherKey,
|
|
RPL4FrontEnd_SelectedWeatherName());
|
|
SteamMatchmaking()->SetLobbyData(gLobby, kLengthKey,
|
|
RPL4FrontEnd_SelectedLengthName());
|
|
}
|
|
}
|
|
|
|
Logical FootballLobby()
|
|
{
|
|
if (!gInLobby)
|
|
{
|
|
return False;
|
|
}
|
|
const char *scenario = SteamMatchmaking()->GetLobbyData(gLobby, kScenarioKey);
|
|
return (scenario != NULL && strcmp(scenario, "football") == 0);
|
|
}
|
|
|
|
//-------------------------------------------------------------------
|
|
// Lobby data read back as a string, empty when the owner has not
|
|
// published it yet (a member can be in the room a beat before the
|
|
// owner's first publish lands).
|
|
//-------------------------------------------------------------------
|
|
const char *LobbyText(const char *key)
|
|
{
|
|
if (!gInLobby)
|
|
{
|
|
return "";
|
|
}
|
|
const char *text = SteamMatchmaking()->GetLobbyData(gLobby, key);
|
|
return (text != NULL) ? text : "";
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Launch: roster into lobby data, peers into the transport, and
|
|
// the hosted-race path primed on the owner
|
|
//---------------------------------------------------------------
|
|
struct MemberInfo
|
|
{
|
|
CSteamID id;
|
|
char ip[32];
|
|
int consolePort;
|
|
int gamePort;
|
|
char name[32];
|
|
char vehicle[24];
|
|
char color[16];
|
|
char badge[24];
|
|
char team[32]; // football pick
|
|
char position[16];
|
|
char netRev[8]; // simulation protocol revision
|
|
char build[24]; // the exact build, see kBuildKey
|
|
Logical camera; // picked Live Cam (acted on for the host)
|
|
Logical published;
|
|
};
|
|
|
|
int CollectMembers(MemberInfo *members)
|
|
{
|
|
int count = SteamMatchmaking()->GetNumLobbyMembers(gLobby);
|
|
if (count > kMaxLobbyMembers)
|
|
{
|
|
count = kMaxLobbyMembers;
|
|
}
|
|
for (int i = 0; i < count; ++i)
|
|
{
|
|
MemberInfo *member = &members[i];
|
|
memset(member, 0, sizeof(*member));
|
|
member->id = SteamMatchmaking()->GetLobbyMemberByIndex(gLobby, i);
|
|
strncpy(member->ip,
|
|
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "ip"),
|
|
sizeof(member->ip) - 1);
|
|
member->consolePort = atoi(
|
|
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "cp"));
|
|
member->gamePort = atoi(
|
|
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "gp"));
|
|
strncpy(member->name,
|
|
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "nm"),
|
|
sizeof(member->name) - 1);
|
|
strncpy(member->vehicle,
|
|
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "vh"),
|
|
sizeof(member->vehicle) - 1);
|
|
strncpy(member->color,
|
|
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "cl"),
|
|
sizeof(member->color) - 1);
|
|
strncpy(member->badge,
|
|
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "bd"),
|
|
sizeof(member->badge) - 1);
|
|
strncpy(member->team,
|
|
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "tm"),
|
|
sizeof(member->team) - 1);
|
|
strncpy(member->position,
|
|
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "ps"),
|
|
sizeof(member->position) - 1);
|
|
strncpy(member->netRev,
|
|
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, kNetRevKey),
|
|
sizeof(member->netRev) - 1);
|
|
strncpy(member->build,
|
|
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, kBuildKey),
|
|
sizeof(member->build) - 1);
|
|
member->camera = (atoi(
|
|
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, kCamKey)) != 0)
|
|
? True : False;
|
|
member->published =
|
|
member->ip[0] != '\0' && member->consolePort > 0 && member->gamePort > 0;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
void RegisterRoster(MemberInfo *members, int count)
|
|
{
|
|
SteamNetTransport_SetEnginePorts(1501);
|
|
for (int i = 0; i < count; ++i)
|
|
{
|
|
if (members[i].published)
|
|
{
|
|
SteamNetTransport_RegisterPeer(
|
|
members[i].ip, members[i].consolePort, members[i].gamePort,
|
|
members[i].id.ConvertToUint64());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Owner side: prime RPL4FE + WinMain's hosted-race path
|
|
void PrimeHostedRace(MemberInfo *members, int count)
|
|
{
|
|
static char pods_env[512];
|
|
static char addr_env[64];
|
|
static char port_env[32];
|
|
|
|
CSteamID self = SteamUser()->GetSteamID();
|
|
FEHostedPilot pilots[kMaxLobbyMembers];
|
|
int pilot_count = 0;
|
|
char pods[512] = "";
|
|
|
|
for (int i = 0; i < count; ++i)
|
|
{
|
|
if (members[i].id == self || !members[i].published)
|
|
{
|
|
continue;
|
|
}
|
|
FEHostedPilot *pilot = &pilots[pilot_count++];
|
|
memset(pilot, 0, sizeof(*pilot));
|
|
sprintf(pilot->address, "%s:1502", members[i].ip);
|
|
strncpy(pilot->name, members[i].name, sizeof(pilot->name) - 1);
|
|
strncpy(pilot->vehicle, members[i].vehicle, sizeof(pilot->vehicle) - 1);
|
|
strncpy(pilot->color, members[i].color, sizeof(pilot->color) - 1);
|
|
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')
|
|
{
|
|
strcat(pods, ",");
|
|
}
|
|
strcat(pods, members[i].ip);
|
|
strcat(pods, ":1501");
|
|
}
|
|
|
|
RPL4FrontEnd_SetHostedPilots(
|
|
SteamNetTransport_GetFakeAddressString(), pilots, pilot_count);
|
|
|
|
sprintf(pods_env, "RP412HOSTPODS=%s", pods);
|
|
putenv(pods_env);
|
|
sprintf(addr_env, "RP412HOSTADDR=%s", SteamNetTransport_GetFakeAddressString());
|
|
putenv(addr_env);
|
|
strcpy(port_env, "RP412HOSTPORT=1501");
|
|
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 member's side of the same coin: has anything happened that this
|
|
// station must act on? Answers in the header's RPL4LobbyMemberPoll
|
|
// values - nothing / launch (peers already registered) / closed (we
|
|
// have already left).
|
|
//
|
|
int MemberPollNow()
|
|
{
|
|
if (!gInLobby)
|
|
{
|
|
return MemberPollClosed;
|
|
}
|
|
|
|
//
|
|
// The host leaving hands Steam's lobby ownership to somebody
|
|
// else - which would silently turn a joiner's page into a host's.
|
|
// For now there is one host and it is whoever opened the lobby:
|
|
// if ownership lands on us, the host is gone, so fold the room.
|
|
//
|
|
if (IsOwner())
|
|
{
|
|
DEBUG_STREAM << "Lobby: the host left - closing\n" << std::flush;
|
|
LeaveLobbyNow();
|
|
return MemberPollClosed;
|
|
}
|
|
|
|
//
|
|
// A room whose owner simulates differently than we do would
|
|
// desynchronize silently rather than fail, so sit the race out
|
|
// instead of flying into it.
|
|
//
|
|
const char *owner_rev = LobbyText(kNetRevKey);
|
|
if (owner_rev[0] != '\0' &&
|
|
strcmp(owner_rev, kNetRevision) != 0)
|
|
{
|
|
DEBUG_STREAM << "Lobby: owner simulates like rev '"
|
|
<< owner_rev << "', we are rev '" << kNetRevision
|
|
<< "' - not launching\n" << std::flush;
|
|
LeaveLobbyNow();
|
|
return MemberPollClosed;
|
|
}
|
|
|
|
const char *go = SteamMatchmaking()->GetLobbyData(gLobby, kGoKey);
|
|
if (go != NULL && go[0] != '\0')
|
|
{
|
|
int nonce = atoi(go);
|
|
if (nonce > gLastGoNonce)
|
|
{
|
|
gLastGoNonce = nonce;
|
|
// register every rostered peer with the transport
|
|
SteamNetTransport_SetEnginePorts(1501);
|
|
const char *cursor = strchr(go, ':');
|
|
cursor = (cursor != NULL) ? cursor + 1 : go;
|
|
while (*cursor != '\0')
|
|
{
|
|
char ip[32];
|
|
int console_port = 0, game_port = 0;
|
|
unsigned __int64 steam_id = 0;
|
|
int n = 0;
|
|
while (cursor[n] != '\0' && cursor[n] != '|' &&
|
|
n < (int) sizeof(ip) - 1)
|
|
{
|
|
ip[n] = cursor[n];
|
|
++n;
|
|
}
|
|
ip[n] = '\0';
|
|
cursor += n;
|
|
if (*cursor == '|')
|
|
{
|
|
console_port = atoi(++cursor);
|
|
while (*cursor != '\0' && *cursor != '|') ++cursor;
|
|
}
|
|
if (*cursor == '|')
|
|
{
|
|
game_port = atoi(++cursor);
|
|
while (*cursor != '\0' && *cursor != '|' && *cursor != ';') ++cursor;
|
|
}
|
|
if (*cursor == '|')
|
|
{
|
|
steam_id = _strtoui64(++cursor, NULL, 10);
|
|
}
|
|
while (*cursor != '\0' && *cursor != ';') ++cursor;
|
|
if (*cursor == ';') ++cursor;
|
|
|
|
if (ip[0] != '\0' && console_port > 0 && game_port > 0)
|
|
{
|
|
SteamNetTransport_RegisterPeer(
|
|
ip, console_port, game_port, steam_id);
|
|
}
|
|
}
|
|
return MemberPollLaunch;
|
|
}
|
|
}
|
|
return MemberPollNothing;
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// The room screen (front-end style: green on black)
|
|
//---------------------------------------------------------------
|
|
const COLORREF kGreenBright = RGB(64, 255, 64);
|
|
const COLORREF kGreenDim = RGB(24, 140, 24);
|
|
|
|
struct RoomState
|
|
{
|
|
HWND window;
|
|
HFONT textFont;
|
|
HFONT titleFont;
|
|
RECT launchRect; // owner only
|
|
RECT leaveRect;
|
|
RECT teamRect; // football: cycle my team
|
|
RECT positionRect; // football: cycle my position
|
|
Logical launchClicked;
|
|
Logical leaveClicked;
|
|
Logical pickChanged;
|
|
Logical closed;
|
|
MemberInfo members[kMaxLobbyMembers];
|
|
int memberCount;
|
|
};
|
|
|
|
RoomState *gRoom = NULL;
|
|
|
|
void PaintRoom(RoomState *room)
|
|
{
|
|
PAINTSTRUCT ps;
|
|
HDC hdc = BeginPaint(room->window, &ps);
|
|
|
|
RECT client;
|
|
GetClientRect(room->window, &client);
|
|
|
|
HDC mem = CreateCompatibleDC(hdc);
|
|
HBITMAP surface = CreateCompatibleBitmap(hdc, client.right, client.bottom);
|
|
HBITMAP old_surface = (HBITMAP) SelectObject(mem, surface);
|
|
|
|
FillRect(mem, &client, (HBRUSH) GetStockObject(BLACK_BRUSH));
|
|
SetBkMode(mem, TRANSPARENT);
|
|
|
|
SelectObject(mem, room->titleFont);
|
|
SetTextColor(mem, kGreenBright);
|
|
RECT title = client;
|
|
title.top = client.bottom / 28;
|
|
title.bottom = title.top + client.bottom / 10;
|
|
DrawTextA(mem,
|
|
FootballLobby() ? "STEAM FOOTBALL LOBBY" : "STEAM RACE LOBBY",
|
|
-1, &title, DT_CENTER | DT_TOP | DT_SINGLELINE);
|
|
|
|
SelectObject(mem, room->textFont);
|
|
// wider than the old middle-half: the right column now carries
|
|
// full catalog names, and in football three of them
|
|
int left = client.right / 6;
|
|
int right = client.right - client.right / 6;
|
|
char text[128];
|
|
|
|
//---------------------------------------------------------------
|
|
// The buttons are laid out upward from the bottom edge while the
|
|
// roster runs down from the top, so everything between the title
|
|
// and the topmost button has to share one band.
|
|
//---------------------------------------------------------------
|
|
int ceiling = client.bottom;
|
|
if (FootballLobby() && room->teamRect.top > 0)
|
|
{
|
|
ceiling = room->teamRect.top;
|
|
}
|
|
else if (IsOwner() && room->launchRect.top > 0)
|
|
{
|
|
ceiling = room->launchRect.top;
|
|
}
|
|
else if (room->leaveRect.top > 0)
|
|
{
|
|
ceiling = room->leaveRect.top;
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Size the rows to that band rather than to a fixed fraction of
|
|
// the window: a full lobby is eight players, and eight rows at a
|
|
// sixteenth each would run off the bottom of every window we
|
|
// support. Counted in half rows, the band holds
|
|
//
|
|
// setup lines + gap + kMaxLobbyMembers rows + gap + hint
|
|
//
|
|
// so the row height falls out of the space actually available.
|
|
// It never grows past the old sixteenth (a two-player lobby
|
|
// should not have circus-sized rows) and never shrinks below the
|
|
// font, which is what would actually break - overlapping text.
|
|
//---------------------------------------------------------------
|
|
// tmHeight already includes the font's own leading, so a row that
|
|
// tall cannot clip or collide - asking for more than that just
|
|
// costs setup lines the room would rather keep.
|
|
TEXTMETRICA metrics;
|
|
GetTextMetricsA(mem, &metrics);
|
|
int min_row = metrics.tmHeight + 2;
|
|
int max_row = client.bottom / 16;
|
|
|
|
int setup_top = title.bottom + client.bottom / 64;
|
|
int band = ceiling - setup_top;
|
|
|
|
int setup_lines = 2;
|
|
int row_h = 0;
|
|
for (;;)
|
|
{
|
|
// halves: setup, half gap, roster, half gap, hint
|
|
int halves = 2 * setup_lines + 1 + 2 * kMaxLobbyMembers + 1 + 2;
|
|
row_h = (2 * band) / halves;
|
|
if (row_h >= min_row || setup_lines == 0)
|
|
{
|
|
break;
|
|
}
|
|
--setup_lines; // buy room back from the setup lines
|
|
}
|
|
if (row_h > max_row) row_h = max_row;
|
|
if (row_h < 12) row_h = 12;
|
|
|
|
//---------------------------------------------------------------
|
|
// On a window too short to give eight rows the room the standard
|
|
// font wants, draw this block smaller rather than letting the
|
|
// rows overlap each other or run under the buttons. Nobody plays
|
|
// at that size, but a squeezed roster still has to be readable.
|
|
//---------------------------------------------------------------
|
|
HFONT squeezed = NULL;
|
|
HFONT previous = NULL;
|
|
if (row_h < min_row)
|
|
{
|
|
squeezed = CreateFontA(-(row_h * 2 / 3), 0, 0, 0, FW_NORMAL,
|
|
FALSE, FALSE, FALSE, ANSI_CHARSET, OUT_TT_PRECIS,
|
|
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
|
|
FIXED_PITCH | FF_MODERN, "Consolas");
|
|
if (squeezed != NULL)
|
|
{
|
|
previous = (HFONT) SelectObject(mem, squeezed);
|
|
}
|
|
}
|
|
|
|
int top = setup_top;
|
|
|
|
const char *map_name = LobbyText(kMapKey);
|
|
if (map_name[0] != '\0' && setup_lines > 0)
|
|
{
|
|
RECT setup;
|
|
setup.left = client.right / 12;
|
|
setup.right = client.right - client.right / 12;
|
|
setup.top = setup_top;
|
|
setup.bottom = setup.top + row_h;
|
|
|
|
SetTextColor(mem, kGreenBright);
|
|
DrawTextA(mem, map_name, -1, &setup,
|
|
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
|
|
|
//-----------------------------------------------------------
|
|
// Conditions on the line below. Game length is the host's
|
|
// pick too, and the one people ask about first.
|
|
//-----------------------------------------------------------
|
|
const char *time_name = LobbyText(kTimeKey);
|
|
const char *weather_name = LobbyText(kWeatherKey);
|
|
const char *length_name = LobbyText(kLengthKey);
|
|
text[0] = '\0';
|
|
if (time_name[0] != '\0')
|
|
{
|
|
strcat(text, time_name);
|
|
}
|
|
if (weather_name[0] != '\0')
|
|
{
|
|
if (text[0] != '\0') strcat(text, " - ");
|
|
strcat(text, weather_name);
|
|
}
|
|
if (length_name[0] != '\0')
|
|
{
|
|
if (text[0] != '\0') strcat(text, " - ");
|
|
strcat(text, length_name);
|
|
}
|
|
if (text[0] != '\0' && setup_lines > 1)
|
|
{
|
|
setup.top = setup.bottom;
|
|
setup.bottom = setup.top + row_h;
|
|
SetTextColor(mem, kGreenDim);
|
|
DrawTextA(mem, text, -1, &setup,
|
|
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
|
}
|
|
|
|
if (setup.bottom + row_h / 2 > top)
|
|
{
|
|
top = setup.bottom + row_h / 2;
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < room->memberCount; ++i)
|
|
{
|
|
MemberInfo *member = &room->members[i];
|
|
RECT row;
|
|
row.left = left;
|
|
row.right = right;
|
|
row.top = top + i * row_h;
|
|
row.bottom = row.top + row_h;
|
|
|
|
Logical is_owner_row =
|
|
SteamMatchmaking()->GetLobbyOwner(gLobby) == member->id;
|
|
SetTextColor(mem, member->published ? kGreenBright : kGreenDim);
|
|
sprintf(text, "%s%s", member->name[0] ? member->name : "(joining...)",
|
|
is_owner_row ? " [HOST]" : "");
|
|
DrawTextA(mem, text, -1, &row, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
|
|
|
//-----------------------------------------------------------
|
|
// The right column is what this player brings. Football is a
|
|
// team sheet - team colors and position - but the VTV still
|
|
// decides how they play it, so name it there too. Keys
|
|
// travel on the wire; the catalogs turn them back into
|
|
// names, so nobody reads "bttlbrg".
|
|
//-----------------------------------------------------------
|
|
//
|
|
// A build the room cannot race with is worth saying before
|
|
// anything about loadouts. A joiner is now turned away at the
|
|
// door, so this should only ever show for a member who was
|
|
// already seated when the guard arrived - but the launch check
|
|
// still refuses on it, and a host is owed the reason.
|
|
//
|
|
if (member->published && member->build[0] != '\0' &&
|
|
strcmp(member->build, RP412_VERSION) != 0)
|
|
{
|
|
sprintf(text, "BUILD %s", member->build);
|
|
DrawTextA(mem, text, -1, &row,
|
|
DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
|
|
}
|
|
//
|
|
// A Live Cam brings no vehicle, so the row says the role where
|
|
// the loadout would go - ANY row now, since the egg honours a
|
|
// member's pick the same as the host's. Football first, though:
|
|
// there are no cameras in football (the egg builder strips the
|
|
// pick), so a football room shows the team sheet and saying
|
|
// LIVE CAM there would be the room lying about the grid.
|
|
//
|
|
else if (FootballLobby())
|
|
{
|
|
if (member->team[0] != '\0')
|
|
{
|
|
sprintf(text, "%s - %s - %s",
|
|
RPL4FrontEnd_VehicleNameForKey(member->vehicle),
|
|
RPL4FrontEnd_TeamNameForKey(member->team),
|
|
RPL4FrontEnd_PositionNameForKey(member->position));
|
|
DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
|
|
}
|
|
}
|
|
else if (member->camera)
|
|
{
|
|
DrawTextA(mem, "LIVE CAM", -1, &row,
|
|
DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
|
|
}
|
|
else if (member->vehicle[0] != '\0')
|
|
{
|
|
sprintf(text, "%s - %s",
|
|
RPL4FrontEnd_VehicleNameForKey(member->vehicle),
|
|
member->color);
|
|
DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
|
|
}
|
|
}
|
|
|
|
SetTextColor(mem, kGreenDim);
|
|
RECT hint;
|
|
hint.left = left;
|
|
hint.right = right;
|
|
hint.top = top + kMaxLobbyMembers * row_h + row_h / 2;
|
|
hint.bottom = hint.top + row_h;
|
|
DrawTextA(mem,
|
|
IsOwner() ? "You are the console: launch when everyone is in."
|
|
: "Waiting for the host to launch...",
|
|
-1, &hint, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
|
|
|
// back to the room font for the buttons, which have their own room
|
|
if (squeezed != NULL)
|
|
{
|
|
SelectObject(mem, previous);
|
|
DeleteObject(squeezed);
|
|
}
|
|
|
|
HBRUSH frame = CreateSolidBrush(kGreenBright);
|
|
|
|
//
|
|
// Football: my own team and position, click to cycle
|
|
//
|
|
if (FootballLobby())
|
|
{
|
|
FrameRect(mem, &room->teamRect, frame);
|
|
SetTextColor(mem, kGreenBright);
|
|
sprintf(text, "MY TEAM: %s",
|
|
RPL4FrontEnd_TeamName(RPL4FrontEnd_GetTeamIndex()));
|
|
DrawTextA(mem, text, -1, &room->teamRect,
|
|
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
|
|
|
FrameRect(mem, &room->positionRect, frame);
|
|
sprintf(text, "MY POSITION: %s",
|
|
RPL4FrontEnd_PositionName(RPL4FrontEnd_GetPositionIndex()));
|
|
DrawTextA(mem, text, -1, &room->positionRect,
|
|
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
|
}
|
|
if (IsOwner())
|
|
{
|
|
FrameRect(mem, &room->launchRect, frame);
|
|
SetTextColor(mem, kGreenBright);
|
|
DrawTextA(mem, "L A U N C H G A M E", -1, &room->launchRect,
|
|
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
|
}
|
|
FrameRect(mem, &room->leaveRect, frame);
|
|
SetTextColor(mem, kGreenBright);
|
|
DrawTextA(mem, "LEAVE LOBBY", -1, &room->leaveRect,
|
|
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
|
DeleteObject(frame);
|
|
|
|
BitBlt(hdc, 0, 0, client.right, client.bottom, mem, 0, 0, SRCCOPY);
|
|
|
|
SelectObject(mem, old_surface);
|
|
DeleteObject(surface);
|
|
DeleteDC(mem);
|
|
EndPaint(room->window, &ps);
|
|
}
|
|
|
|
LRESULT CALLBACK
|
|
RoomWndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
|
|
{
|
|
RoomState *room = gRoom;
|
|
|
|
switch (message)
|
|
{
|
|
case WM_PAINT:
|
|
if (room != NULL)
|
|
{
|
|
PaintRoom(room);
|
|
return 0;
|
|
}
|
|
break;
|
|
|
|
case WM_LBUTTONDOWN:
|
|
if (room != NULL)
|
|
{
|
|
POINT point = { (int)(short) LOWORD(lParam), (int)(short) HIWORD(lParam) };
|
|
if (IsOwner() && PtInRect(&room->launchRect, point))
|
|
{
|
|
room->launchClicked = True;
|
|
}
|
|
else if (PtInRect(&room->leaveRect, point))
|
|
{
|
|
room->leaveClicked = True;
|
|
}
|
|
else if (FootballLobby() && PtInRect(&room->teamRect, point))
|
|
{
|
|
RPL4FrontEnd_SetTeamIndex(
|
|
(RPL4FrontEnd_GetTeamIndex() + 1) % RPL4FrontEnd_TeamCount());
|
|
room->pickChanged = True;
|
|
}
|
|
else if (FootballLobby() && PtInRect(&room->positionRect, point))
|
|
{
|
|
RPL4FrontEnd_SetPositionIndex(
|
|
(RPL4FrontEnd_GetPositionIndex() + 1) % RPL4FrontEnd_PositionCount());
|
|
room->pickChanged = True;
|
|
}
|
|
return 0;
|
|
}
|
|
break;
|
|
|
|
case WM_ERASEBKGND:
|
|
return 1;
|
|
}
|
|
return DefWindowProcA(hwnd, message, wParam, lParam);
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// The room loop: pump Steam + Windows, watch for launch/leave
|
|
//---------------------------------------------------------------
|
|
int RunRoom(HINSTANCE instance, HWND main_window)
|
|
{
|
|
static Logical class_registered = False;
|
|
if (!class_registered)
|
|
{
|
|
class_registered = True;
|
|
WNDCLASSA window_class;
|
|
memset(&window_class, 0, sizeof(window_class));
|
|
window_class.style = CS_HREDRAW | CS_VREDRAW;
|
|
window_class.lpfnWndProc = RoomWndProc;
|
|
window_class.hInstance = instance;
|
|
window_class.hCursor = LoadCursor(NULL, IDC_ARROW);
|
|
window_class.hbrBackground = (HBRUSH) GetStockObject(BLACK_BRUSH);
|
|
window_class.lpszClassName = "RPLobby";
|
|
RegisterClassA(&window_class);
|
|
}
|
|
|
|
RECT client;
|
|
GetClientRect(main_window, &client);
|
|
|
|
RoomState room;
|
|
memset(&room, 0, sizeof(room));
|
|
room.window = CreateWindowExA(
|
|
0, "RPLobby", "",
|
|
WS_CHILD | WS_VISIBLE,
|
|
0, 0, client.right, client.bottom,
|
|
main_window, NULL, instance, NULL);
|
|
if (room.window == NULL)
|
|
{
|
|
return LobbyRoomClosed;
|
|
}
|
|
|
|
int text_h = client.bottom / 40;
|
|
if (text_h < 16) text_h = 16;
|
|
if (text_h > 24) text_h = 24;
|
|
room.textFont = CreateFontA(-(text_h * 3 / 2), 0, 0, 0, FW_NORMAL,
|
|
FALSE, FALSE, FALSE, ANSI_CHARSET, OUT_DEFAULT_PRECIS,
|
|
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_MODERN,
|
|
"Consolas");
|
|
room.titleFont = CreateFontA(-(text_h * 2), 0, 0, 0, FW_BOLD,
|
|
FALSE, FALSE, FALSE, ANSI_CHARSET, OUT_DEFAULT_PRECIS,
|
|
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_MODERN,
|
|
"Consolas");
|
|
|
|
int row_h = client.bottom / 36;
|
|
if (row_h < 18) row_h = 18;
|
|
if (row_h > 30) row_h = 30;
|
|
int col_w = client.right / 4;
|
|
room.launchRect.left = (client.right - col_w) / 2;
|
|
room.launchRect.right = room.launchRect.left + col_w;
|
|
room.launchRect.top = client.bottom - 6 * row_h;
|
|
room.launchRect.bottom = room.launchRect.top + 2 * row_h;
|
|
room.leaveRect.left = (client.right - col_w / 2) / 2;
|
|
room.leaveRect.right = room.leaveRect.left + col_w / 2;
|
|
room.leaveRect.top = client.bottom - 3 * row_h;
|
|
room.leaveRect.bottom = client.bottom - row_h;
|
|
|
|
// football pick buttons, above the launch button
|
|
room.teamRect.left = (client.right - col_w) / 2;
|
|
room.teamRect.right = room.teamRect.left + col_w;
|
|
room.teamRect.top = client.bottom - 12 * row_h;
|
|
room.teamRect.bottom = room.teamRect.top + 2 * row_h;
|
|
room.positionRect.left = room.teamRect.left;
|
|
room.positionRect.right = room.teamRect.right;
|
|
room.positionRect.top = client.bottom - 9 * row_h;
|
|
room.positionRect.bottom = room.positionRect.top + 2 * row_h;
|
|
|
|
gRoom = &room;
|
|
PublishMemberData();
|
|
|
|
int outcome = -1;
|
|
DWORD last_refresh = 0;
|
|
while (outcome < 0)
|
|
{
|
|
MSG msg;
|
|
while (PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE))
|
|
{
|
|
if (msg.message == WM_QUIT)
|
|
{
|
|
room.closed = True;
|
|
}
|
|
TranslateMessage(&msg);
|
|
DispatchMessageA(&msg);
|
|
}
|
|
SteamAPI_RunCallbacks();
|
|
|
|
if (!IsWindow(main_window) || room.closed)
|
|
{
|
|
outcome = LobbyRoomClosed;
|
|
break;
|
|
}
|
|
//
|
|
// A pick changed: republish so everyone's roster updates
|
|
//
|
|
if (room.pickChanged)
|
|
{
|
|
room.pickChanged = False;
|
|
PublishMemberData();
|
|
room.memberCount = CollectMembers(room.members);
|
|
InvalidateRect(room.window, NULL, FALSE);
|
|
RedrawWindow(room.window, NULL, NULL, RDW_UPDATENOW);
|
|
}
|
|
|
|
if (room.leaveClicked)
|
|
{
|
|
LeaveLobbyNow();
|
|
outcome = LobbyRoomLeft;
|
|
break;
|
|
}
|
|
|
|
//
|
|
// Owner launch: roster into lobby data + local prime
|
|
//
|
|
if (room.launchClicked)
|
|
{
|
|
room.launchClicked = False;
|
|
if (OwnerLaunchNow())
|
|
{
|
|
outcome = LobbyLaunchHost;
|
|
break;
|
|
}
|
|
}
|
|
|
|
//
|
|
// Member: watch for the owner's go signal
|
|
//
|
|
if (!IsOwner())
|
|
{
|
|
int poll = MemberPollNow();
|
|
|
|
if (poll == MemberPollLaunch)
|
|
{
|
|
outcome = LobbyLaunchMember;
|
|
break;
|
|
}
|
|
if (poll == MemberPollClosed)
|
|
{
|
|
outcome = LobbyRoomLeft;
|
|
break;
|
|
}
|
|
}
|
|
|
|
//
|
|
// Refresh the member list once a second
|
|
//
|
|
DWORD now = GetTickCount();
|
|
if ((LONG)(now - last_refresh) >= 1000)
|
|
{
|
|
last_refresh = now;
|
|
room.memberCount = CollectMembers(room.members);
|
|
InvalidateRect(room.window, NULL, FALSE);
|
|
RedrawWindow(room.window, NULL, NULL, RDW_UPDATENOW);
|
|
}
|
|
Sleep(50);
|
|
}
|
|
|
|
DestroyWindow(room.window);
|
|
DeleteObject(room.textFont);
|
|
DeleteObject(room.titleFont);
|
|
gRoom = NULL;
|
|
return outcome;
|
|
}
|
|
}
|
|
|
|
//########################################################################
|
|
// Public API
|
|
//########################################################################
|
|
|
|
Logical
|
|
RPL4Lobby_Available()
|
|
{
|
|
return SteamNetTransport_GetFakeAddressString()[0] != '\0';
|
|
}
|
|
|
|
//
|
|
// The environ.ini switch, read the same way RPL4.CPP reads it to decide
|
|
// whether to install the transport at all. Says nothing about whether the
|
|
// Steam client was actually there.
|
|
//
|
|
Logical
|
|
RPL4Lobby_Configured()
|
|
{
|
|
const char *steam_switch = getenv("RP412STEAM");
|
|
return (steam_switch != NULL && atoi(steam_switch) != 0) ? True : False;
|
|
}
|
|
|
|
Logical
|
|
RPL4Lobby_InRoom()
|
|
{
|
|
return gInLobby;
|
|
}
|
|
|
|
//
|
|
// Host and Join are the two places that reach Steam without the
|
|
// transport having got there first, so they carry their own guard.
|
|
// steam_api.dll is delay-loaded and calling into it when it is absent
|
|
// raises the helper's fatal exception - the menu already greys these on
|
|
// Available(), but a dead DLL must not depend on the UI for safety.
|
|
//
|
|
namespace
|
|
{
|
|
//
|
|
// The one worldwide search, shared by joining and by the one-host
|
|
// rule: is there an open RP412 lobby right now?
|
|
//
|
|
Logical FindOpenLobby(CSteamID *found)
|
|
{
|
|
gCallDone = False;
|
|
SteamMatchmaking()->AddRequestLobbyListStringFilter(
|
|
kLobbyTagKey, "1", k_ELobbyComparisonEqual);
|
|
// the default lobby search is distance-filtered (roughly same
|
|
// region) - RP412 races are worldwide
|
|
SteamMatchmaking()->AddRequestLobbyListDistanceFilter(
|
|
k_ELobbyDistanceFilterWorldwide);
|
|
SteamAPICall_t call = SteamMatchmaking()->RequestLobbyList();
|
|
gCalls.listResult.Set(call, &gCalls, &LobbyCalls::OnList);
|
|
if (!WaitForCall(15000))
|
|
{
|
|
return False;
|
|
}
|
|
*found = gCallLobby;
|
|
return True;
|
|
}
|
|
}
|
|
|
|
int
|
|
RPL4Lobby_HostOpen()
|
|
{
|
|
if (!SteamNetTransport_ClientLibraryPresent() || gInLobby)
|
|
{
|
|
return gInLobby ? HostOpenCreated : HostOpenFailed;
|
|
}
|
|
|
|
//
|
|
// One host at a time, while the whole flow is young: if an RP412
|
|
// lobby is already open anywhere, this button does not open a second
|
|
// one - the caller says join it instead. Check-then-create is not
|
|
// airtight against two people clicking in the same breath, but for a
|
|
// playtest group coordinating over voice it is the rule they asked
|
|
// for.
|
|
//
|
|
{
|
|
CSteamID existing;
|
|
|
|
if (FindOpenLobby(&existing))
|
|
{
|
|
DEBUG_STREAM << "Lobby: " << existing.ConvertToUint64()
|
|
<< " is already open - one host at a time, join it instead\n"
|
|
<< std::flush;
|
|
return HostOpenLobbyExists;
|
|
}
|
|
}
|
|
|
|
gCallDone = False;
|
|
SteamAPICall_t call =
|
|
SteamMatchmaking()->CreateLobby(k_ELobbyTypePublic, kMaxLobbyMembers);
|
|
gCalls.createResult.Set(call, &gCalls, &LobbyCalls::OnCreated);
|
|
if (!WaitForCall(15000))
|
|
{
|
|
DEBUG_STREAM << "Lobby: CreateLobby failed\n" << std::flush;
|
|
return HostOpenFailed;
|
|
}
|
|
gLobby = gCallLobby;
|
|
gInLobby = True;
|
|
gLastGoNonce = 0;
|
|
SteamMatchmaking()->SetLobbyData(gLobby, kLobbyTagKey, "1");
|
|
SteamMatchmaking()->SetLobbyData(gLobby, kGoKey, "");
|
|
|
|
//
|
|
// 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 HostOpenCreated;
|
|
}
|
|
|
|
//
|
|
// Joining, the same shape as hosting: sit down in the lobby and return -
|
|
// the setup menu stays up, the mission column greys out and mirrors the
|
|
// host's picks, and the go arrives through RPL4Lobby_MemberPoll.
|
|
//
|
|
int
|
|
RPL4Lobby_JoinOpen(HWND main_window)
|
|
{
|
|
if (!SteamNetTransport_ClientLibraryPresent() || gInLobby)
|
|
{
|
|
return gInLobby ? JoinOpenJoined : JoinOpenNothingFound;
|
|
}
|
|
|
|
CSteamID target;
|
|
|
|
if (!FindOpenLobby(&target))
|
|
{
|
|
DEBUG_STREAM << "Lobby: no open race lobby found\n" << std::flush;
|
|
return JoinOpenNothingFound;
|
|
}
|
|
|
|
gCallDone = False;
|
|
SteamAPICall_t call = SteamMatchmaking()->JoinLobby(target);
|
|
gCalls.enterResult.Set(call, &gCalls, &LobbyCalls::OnEnter);
|
|
if (!WaitForCall(15000))
|
|
{
|
|
DEBUG_STREAM << "Lobby: join failed\n" << std::flush;
|
|
return JoinOpenNothingFound;
|
|
}
|
|
gLobby = gCallLobby;
|
|
gInLobby = True;
|
|
|
|
//
|
|
// Refuse a room running a different build, and refuse it HERE rather
|
|
// than at launch - the same door guard the room flow has always had,
|
|
// word for word. See the room version for why it is loud.
|
|
//
|
|
{
|
|
const char *host_build = SteamMatchmaking()->GetLobbyData(gLobby, kBuildKey);
|
|
if (host_build == NULL || strcmp(host_build, RP412_VERSION) != 0)
|
|
{
|
|
DEBUG_STREAM << "Lobby: room is build '"
|
|
<< ((host_build != NULL && host_build[0]) ? host_build : "(older)")
|
|
<< "', we are '" << RP412_VERSION
|
|
<< "' - leaving, everyone must run the same build\n" << std::flush;
|
|
SteamMatchmaking()->LeaveLobby(gLobby);
|
|
gInLobby = False;
|
|
|
|
char said[256];
|
|
sprintf(said,
|
|
"That race is running Red Planet %s.\n"
|
|
"You are running %s.\n\n"
|
|
"Everyone in a race has to be on the same build - the\n"
|
|
"simulation has to agree exactly. Swap to a matching\n"
|
|
"build and join again.",
|
|
(host_build != NULL && host_build[0]) ? host_build : "an older build",
|
|
RP412_VERSION);
|
|
MessageBoxA(main_window, said, "Different build",
|
|
MB_OK | MB_ICONINFORMATION);
|
|
return JoinOpenWrongBuild;
|
|
}
|
|
}
|
|
|
|
// answer only launches newer than anything already in the lobby
|
|
const char *go = SteamMatchmaking()->GetLobbyData(gLobby, kGoKey);
|
|
gLastGoNonce = (go != NULL) ? atoi(go) : 0;
|
|
|
|
// our row, visible to the host's roster immediately
|
|
PublishMemberData();
|
|
DEBUG_STREAM << "Lobby: joined " << gLobby.ConvertToUint64()
|
|
<< " from the setup menu\n" << std::flush;
|
|
return JoinOpenJoined;
|
|
}
|
|
|
|
int
|
|
RPL4Lobby_MemberPoll()
|
|
{
|
|
if (!gInLobby)
|
|
{
|
|
return MemberPollClosed;
|
|
}
|
|
return MemberPollNow();
|
|
}
|
|
|
|
//
|
|
// The host's published mission setup, as display names, for the greyed
|
|
// mission column to mirror. Empty strings before the first publish lands.
|
|
//
|
|
void
|
|
RPL4Lobby_GetSetup(
|
|
char *scenario, char *map, char *time_of_day,
|
|
char *weather, char *length)
|
|
{
|
|
strncpy(scenario, LobbyText(kScenarioKey), 47); scenario[47] = '\0';
|
|
strncpy(map, LobbyText(kMapKey), 47); map[47] = '\0';
|
|
strncpy(time_of_day, LobbyText(kTimeKey), 47); time_of_day[47] = '\0';
|
|
strncpy(weather, LobbyText(kWeatherKey), 47); weather[47] = '\0';
|
|
strncpy(length, LobbyText(kLengthKey), 47); length[47] = '\0';
|
|
}
|
|
|
|
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;
|
|
|
|
Logical football = FootballLobby();
|
|
|
|
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].published)
|
|
{
|
|
note = " ...";
|
|
}
|
|
|
|
//
|
|
// The whole loadout, not just the ride: colour and badge for a
|
|
// race, team and position for football - what the host is about
|
|
// to commit everyone AS, visible before the commit. A camera
|
|
// brings none of it; the pick replaces the lot.
|
|
//
|
|
if (members[i].camera && !football)
|
|
{
|
|
_snprintf(lines[written], 47, "%-12s LIVE CAM%s", name, note);
|
|
}
|
|
else if (football)
|
|
{
|
|
_snprintf(lines[written], 47, "%-12s %s %s %s%s",
|
|
name, members[i].vehicle,
|
|
members[i].team[0] ? members[i].team : "(no team)",
|
|
members[i].position[0] ? members[i].position : "",
|
|
note);
|
|
}
|
|
else
|
|
{
|
|
_snprintf(lines[written], 47, "%-12s %s %s %s%s",
|
|
name, members[i].vehicle, members[i].color,
|
|
(members[i].badge[0] &&
|
|
_stricmp(members[i].badge, "None") != 0)
|
|
? members[i].badge : "",
|
|
note);
|
|
}
|
|
lines[written][47] = '\0';
|
|
++written;
|
|
}
|
|
return written;
|
|
}
|
|
|
|
Logical
|
|
RPL4Lobby_HostLaunch()
|
|
{
|
|
if (!gInLobby || !IsOwner())
|
|
{
|
|
return False;
|
|
}
|
|
return OwnerLaunchNow();
|
|
}
|
|
|
|
void
|
|
RPL4Lobby_Leave()
|
|
{
|
|
LeaveLobbyNow();
|
|
}
|
|
|
|
int
|
|
RPL4Lobby_Join(HINSTANCE instance, HWND main_window)
|
|
{
|
|
if (!SteamNetTransport_ClientLibraryPresent())
|
|
{
|
|
return LobbyRoomLeft;
|
|
}
|
|
|
|
gCallDone = False;
|
|
SteamMatchmaking()->AddRequestLobbyListStringFilter(
|
|
kLobbyTagKey, "1", k_ELobbyComparisonEqual);
|
|
// the default lobby search is distance-filtered (roughly same
|
|
// region) - RP412 races are worldwide
|
|
SteamMatchmaking()->AddRequestLobbyListDistanceFilter(
|
|
k_ELobbyDistanceFilterWorldwide);
|
|
SteamAPICall_t call = SteamMatchmaking()->RequestLobbyList();
|
|
gCalls.listResult.Set(call, &gCalls, &LobbyCalls::OnList);
|
|
if (!WaitForCall(15000))
|
|
{
|
|
DEBUG_STREAM << "Lobby: no open race lobby found\n" << std::flush;
|
|
return LobbyRoomLeft;
|
|
}
|
|
|
|
CSteamID target = gCallLobby;
|
|
gCallDone = False;
|
|
call = SteamMatchmaking()->JoinLobby(target);
|
|
gCalls.enterResult.Set(call, &gCalls, &LobbyCalls::OnEnter);
|
|
if (!WaitForCall(15000))
|
|
{
|
|
DEBUG_STREAM << "Lobby: join failed\n" << std::flush;
|
|
return LobbyRoomLeft;
|
|
}
|
|
gLobby = gCallLobby;
|
|
gInLobby = True;
|
|
|
|
//
|
|
// Refuse a room running a different build, and refuse it HERE rather
|
|
// than at launch. The owner's launch check would catch it, but only
|
|
// after everyone has picked a loadout and pressed go, and all it can do
|
|
// then is silently decline to start - which reads as the host's button
|
|
// being broken. Far better to say so on the way in.
|
|
//
|
|
// The owner publishes its build as lobby data, so this costs one read.
|
|
// An empty string means a host older than the key, which is equally a
|
|
// mismatch: it cannot be trusted to be this build.
|
|
//
|
|
{
|
|
const char *host_build = SteamMatchmaking()->GetLobbyData(gLobby, kBuildKey);
|
|
if (host_build == NULL || strcmp(host_build, RP412_VERSION) != 0)
|
|
{
|
|
DEBUG_STREAM << "Lobby: room is build '"
|
|
<< ((host_build != NULL && host_build[0]) ? host_build : "(older)")
|
|
<< "', we are '" << RP412_VERSION
|
|
<< "' - leaving, everyone must run the same build\n" << std::flush;
|
|
SteamMatchmaking()->LeaveLobby(gLobby);
|
|
gInLobby = False;
|
|
|
|
//
|
|
// Say so on screen, not just in the log. A player who is bounced
|
|
// out of a room with no explanation will try again, and again,
|
|
// and then report that joining is broken - which is precisely
|
|
// the afternoon this guard exists to prevent.
|
|
//
|
|
char said[256];
|
|
sprintf(said,
|
|
"That race is running Red Planet %s.\n"
|
|
"You are running %s.\n\n"
|
|
"Everyone in a race has to be on the same build - the\n"
|
|
"simulation has to agree exactly. Swap to a matching\n"
|
|
"build and join again.",
|
|
(host_build != NULL && host_build[0]) ? host_build : "an older build",
|
|
RP412_VERSION);
|
|
MessageBoxA(main_window, said, "Different build",
|
|
MB_OK | MB_ICONINFORMATION);
|
|
return LobbyRoomLeft;
|
|
}
|
|
}
|
|
|
|
// answer only launches newer than anything already in the lobby
|
|
const char *go = SteamMatchmaking()->GetLobbyData(gLobby, kGoKey);
|
|
gLastGoNonce = (go != NULL) ? atoi(go) : 0;
|
|
DEBUG_STREAM << "Lobby: joined " << gLobby.ConvertToUint64() << "\n" << std::flush;
|
|
return RunRoom(instance, main_window);
|
|
}
|
|
|
|
int
|
|
RPL4Lobby_Room(HINSTANCE instance, HWND main_window)
|
|
{
|
|
if (!gInLobby)
|
|
{
|
|
return LobbyRoomLeft;
|
|
}
|
|
return RunRoom(instance, main_window);
|
|
}
|
|
|
|
void
|
|
RPL4Lobby_PushRaceResults()
|
|
{
|
|
if (!gInLobby || !IsOwner() || RPL4LocalConsole_ResultCount() == 0)
|
|
{
|
|
return;
|
|
}
|
|
SteamAPI_RunCallbacks();
|
|
|
|
char sheet[600];
|
|
sprintf(sheet, "%d:", gLastGoNonce);
|
|
for (int i = 0; i < RPL4LocalConsole_ResultCount(); ++i)
|
|
{
|
|
int host_ID, score;
|
|
if (!RPL4LocalConsole_GetResult(i, &host_ID, &score))
|
|
{
|
|
continue;
|
|
}
|
|
const char *name = RPL4LocalConsole_GetResultName(host_ID);
|
|
char entry[80];
|
|
sprintf(entry, "%d|%d|%.30s;", host_ID, score,
|
|
(name != NULL) ? name : "");
|
|
if (strlen(sheet) + strlen(entry) < sizeof(sheet))
|
|
{
|
|
strcat(sheet, entry);
|
|
}
|
|
}
|
|
SteamMatchmaking()->SetLobbyData(gLobby, kResultsKey, sheet);
|
|
gShownResultsNonce = gLastGoNonce; // the owner sees its own screen
|
|
DEBUG_STREAM << "Lobby: results published (" << sheet << ")\n" << std::flush;
|
|
}
|
|
|
|
void
|
|
RPL4Lobby_PullRaceResults()
|
|
{
|
|
if (!gInLobby || IsOwner() || gLastGoNonce == gShownResultsNonce)
|
|
{
|
|
return;
|
|
}
|
|
|
|
//
|
|
// The owner publishes right after its race teardown - ours may
|
|
// finish first, so give the sheet a moment to arrive.
|
|
//
|
|
// Unless WE aborted. An Alt+Q leaves a race that is still running
|
|
// everywhere else, and its sheet will not exist for minutes - the
|
|
// eight second wait below was eight seconds of frozen window for a
|
|
// player who had just asked to leave. Exit_Code carries the abort
|
|
// (the race loop resets it after the menu); take one free look and
|
|
// otherwise skip - the sheet's nonce keeps it eligible to show
|
|
// after the race actually ends.
|
|
//
|
|
const char *sheet = NULL;
|
|
DWORD deadline = (Exit_Code != 0)
|
|
? GetTickCount() : GetTickCount() + 8 * 1000;
|
|
for (;;)
|
|
{
|
|
SteamAPI_RunCallbacks();
|
|
sheet = SteamMatchmaking()->GetLobbyData(gLobby, kResultsKey);
|
|
if (sheet != NULL && sheet[0] != '\0' && atoi(sheet) == gLastGoNonce)
|
|
{
|
|
break;
|
|
}
|
|
if ((LONG)(GetTickCount() - deadline) >= 0)
|
|
{
|
|
return; // no sheet for this race - skip the screen
|
|
}
|
|
Sleep(100);
|
|
}
|
|
gShownResultsNonce = gLastGoNonce;
|
|
|
|
RPL4LocalConsole_ClearResults();
|
|
const char *cursor = strchr(sheet, ':');
|
|
cursor = (cursor != NULL) ? cursor + 1 : sheet;
|
|
while (*cursor != '\0')
|
|
{
|
|
int host_ID = atoi(cursor);
|
|
int score = 0;
|
|
char name[32] = "";
|
|
while (*cursor != '\0' && *cursor != '|' && *cursor != ';') ++cursor;
|
|
if (*cursor == '|')
|
|
{
|
|
score = atoi(++cursor);
|
|
while (*cursor != '\0' && *cursor != '|' && *cursor != ';') ++cursor;
|
|
}
|
|
if (*cursor == '|')
|
|
{
|
|
++cursor;
|
|
int n = 0;
|
|
while (cursor[n] != '\0' && cursor[n] != ';' &&
|
|
n < (int) sizeof(name) - 1)
|
|
{
|
|
name[n] = cursor[n];
|
|
++n;
|
|
}
|
|
name[n] = '\0';
|
|
cursor += n;
|
|
}
|
|
while (*cursor != '\0' && *cursor != ';') ++cursor;
|
|
if (*cursor == ';') ++cursor;
|
|
|
|
if (host_ID > 0)
|
|
{
|
|
RPL4LocalConsole_InjectResult(host_ID, score, name);
|
|
}
|
|
}
|
|
DEBUG_STREAM << "Lobby: results pulled for race " << gLastGoNonce << "\n" << std::flush;
|
|
}
|
|
|
|
#endif // RP412_STEAM
|