Football: every player picks their own team and position

Closes the gap left by the football commit. Members publish their
picks as lobby member data (tm/ps) alongside their loadout, and the
owner publishes the scenario (sc) so members know when the picks
matter. The lobby room turns into a team sheet: each member row shows
their team and position, and MY TEAM / MY POSITION buttons cycle the
local pick and republish it live, so everyone watches the sides fill
out before the host launches.

The egg builder honors explicit picks and only fills gaps:
- pilots who chose a team get it; the rest are spread across the
  host team and one other so the sides stay balanced
- a team with no volunteer runner promotes its first UNPICKED member,
  never overriding someone who chose crusher or blocker
- if two pilots on a team both claim runner, the first keeps it and
  the second lines up
- colors stay derived: runner in the team runner color, everyone else
  in the team color

Verified: a three-pilot egg puts the host on crusher in team color
with an unpicked teammate promoted to runner in runner color, the
other side gets its own runner, and the teams/pilots blocks group
correctly; the lobby room cycles picks and repaints the roster; both
scenarios still pass their single-player regressions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-24 19:59:42 -05:00
co-authored by Claude Fable 5
parent 3194d3f973
commit 4b25f89e6f
4 changed files with 379 additions and 48 deletions
+216 -37
View File
@@ -250,6 +250,8 @@ namespace
char vehicle[24];
char color[16];
char badge[24];
char team[32]; // football pick, "" = assign for them
char position[16];
};
// lobby-fed override (real personas + loadouts); empty = env parsing
@@ -273,6 +275,8 @@ namespace
gHostedPilots[i].color[0] ? gHostedPilots[i].color : "Red");
strcpy(extras[i].badge,
gHostedPilots[i].badge[0] ? gHostedPilots[i].badge : "None");
strcpy(extras[i].team, gHostedPilots[i].team);
strcpy(extras[i].position, gHostedPilots[i].position);
}
return gHostedPilotCount;
}
@@ -324,6 +328,7 @@ namespace
}
ExtraPilot *pilot = &extras[extra_count];
memset(pilot, 0, sizeof(*pilot));
sprintf(pilot->address, "%s:%d", entry, console_port + 1);
sprintf(pilot->name, "PILOT %d", extra_count + 2);
strcpy(pilot->vehicle, kVehicles[
@@ -797,6 +802,7 @@ namespace
const char *badge;
int team; // kTeams index, -1 outside football
const char *position;
Logical chosePosition; // picked it themselves
};
PilotEntry pilots[maxExtraPilots + 1];
int pilot_count = 0;
@@ -809,6 +815,7 @@ namespace
owner->badge = kBadges[fe->selection[GroupBadge]].key;
owner->team = -1;
owner->position = NULL;
owner->chosePosition = False;
for (int p = 0; p < extra_count; ++p)
{
PilotEntry *pilot = &pilots[pilot_count++];
@@ -819,47 +826,121 @@ namespace
pilot->badge = extras[p].badge;
pilot->team = -1;
pilot->position = NULL;
pilot->chosePosition = False;
}
//
// Football: assign teams and positions, derive colors
// Football: every pilot's own team/position pick is honored;
// unset picks get filled in, then each team is normalized to
// exactly one runner (the console's rule).
//
int teams_used[2] = { 0, 1 };
if (football)
{
teams_used[0] = fe->selection[GroupTeam];
teams_used[1] = (teams_used[0] + 1) % FE_COUNT(kTeams);
int runner_on_team[2] = { 0, 0 };
int line_position[2] = { 0, 0 }; // crusher/blocker rotation
owner->team = 0;
owner->position = kPositions[fe->selection[GroupPosition]].key;
if (strcmp(owner->position, "runner") == 0)
{
runner_on_team[0] = 1;
}
//
// 1. Teams: the host's pick, then each member's, then
// alternate whoever did not choose across the two
// most-used teams so the sides stay balanced.
//
owner->team = fe->selection[GroupTeam];
for (int p = 1; p < pilot_count; ++p)
{
int side = p % 2; // alternate: B, A, B, A...
side = (side == 1) ? 1 : 0;
pilots[p].team = side;
if (!runner_on_team[side])
for (int t = 0; t < FE_COUNT(kTeams); ++t)
{
pilots[p].position = "runner";
runner_on_team[side] = 1;
}
else
{
pilots[p].position =
(line_position[side]++ % 2) ? "blocker" : "crusher";
if (strcmp(extras[p - 1].team, kTeams[t].key) == 0)
{
pilots[p].team = t;
break;
}
}
}
int fallback_team = (owner->team + 1) % FE_COUNT(kTeams);
int unassigned = 0;
for (int p = 1; p < pilot_count; ++p)
{
if (pilots[p].team < 0)
{
pilots[p].team = (unassigned++ % 2) ? owner->team : fallback_team;
}
}
//
// 2. Positions: honor each pick, default the rest to the
// line, then give every team exactly one runner.
//
for (int p = 1; p < pilot_count; ++p)
{
const char *pick = extras[p - 1].position;
for (int i = 0; i < FE_COUNT(kPositions); ++i)
{
if (strcmp(pick, kPositions[i].key) == 0)
{
pilots[p].position = kPositions[i].key;
pilots[p].chosePosition = True;
break;
}
}
}
owner->position = kPositions[fe->selection[GroupPosition]].key;
owner->chosePosition = True;
for (int t = 0; t < FE_COUNT(kTeams); ++t)
{
int members = 0, runner = -1, spare = -1, line = 0;
for (int p = 0; p < pilot_count; ++p)
{
if (pilots[p].team != t)
{
continue;
}
++members;
if (spare < 0 && !pilots[p].chosePosition)
{
spare = p; // nobody's plans to disturb
}
if (pilots[p].position != NULL &&
strcmp(pilots[p].position, "runner") == 0)
{
if (runner < 0)
{
runner = p; // first runner keeps the ball
}
else
{
// two pilots claimed it - the later one lines up
pilots[p].position = NULL;
pilots[p].chosePosition = False;
}
}
}
if (members == 0)
{
continue;
}
if (runner < 0 && spare >= 0)
{
// no volunteer: the first pilot without a pick of
// their own runs (explicit picks are never overridden)
pilots[spare].position = "runner";
runner = spare;
}
for (int p = 0; p < pilot_count; ++p)
{
if (pilots[p].team == t && p != runner &&
pilots[p].position == NULL)
{
pilots[p].position = (line++ % 2) ? "crusher" : "blocker";
}
}
}
//
// 3. Colors follow team and position (runner wears the
// team's runner color - that is how you spot the ball)
//
for (int p = 0; p < pilot_count; ++p)
{
const TeamEntry *team = &kTeams[teams_used[pilots[p].team]];
const TeamEntry *team = &kTeams[pilots[p].team];
pilots[p].color =
(strcmp(pilots[p].position, "runner") == 0)
? team->runnerColor : team->teamColor;
@@ -921,7 +1002,7 @@ namespace
egg += line;
if (football)
{
sprintf(line, "team=team::%s\n", kTeams[teams_used[pilots[p].team]].key);
sprintf(line, "team=team::%s\n", kTeams[pilots[p].team].key);
egg += line;
sprintf(line, "position=%s\n", pilots[p].position);
egg += line;
@@ -933,24 +1014,26 @@ namespace
//
if (football)
{
Logical team_present[2] = { False, False };
for (int p = 0; p < pilot_count; ++p)
{
team_present[pilots[p].team] = True;
}
std::string teams_list = "[teams]\n";
std::string team_sections, pilots_sections;
std::string teambitmap_list = "[teambitmap]\n";
std::string teambitmap_sections;
int bitmap_index = 1;
for (int side = 0; side < 2; ++side)
for (int side = 0; side < FE_COUNT(kTeams); ++side)
{
if (!team_present[side])
Logical team_present = False;
for (int p = 0; p < pilot_count; ++p)
{
if (pilots[p].team == side)
{
team_present = True;
}
}
if (!team_present)
{
continue;
}
const char *team_key = kTeams[teams_used[side]].key;
const char *team_key = kTeams[side].key;
sprintf(line, "team=team::%s\n", team_key);
teams_list += line;
sprintf(line, "[team::%s]\nbitmapindex=%d\npilots=pilots::%s\n",
@@ -1297,6 +1380,102 @@ void
strcpy(badge, kBadges[b].key);
}
//########################################################################
// Football team / position picks (the lobby publishes and cycles these)
//########################################################################
int
RPL4FrontEnd_TeamCount()
{
return FE_COUNT(kTeams);
}
const char *
RPL4FrontEnd_TeamName(int index)
{
if (index < 0 || index >= FE_COUNT(kTeams))
{
return "";
}
return kTeams[index].name;
}
const char *
RPL4FrontEnd_TeamKey(int index)
{
if (index < 0 || index >= FE_COUNT(kTeams))
{
return "";
}
return kTeams[index].key;
}
int
RPL4FrontEnd_PositionCount()
{
return FE_COUNT(kPositions);
}
const char *
RPL4FrontEnd_PositionName(int index)
{
if (index < 0 || index >= FE_COUNT(kPositions))
{
return "";
}
return kPositions[index].name;
}
const char *
RPL4FrontEnd_PositionKey(int index)
{
if (index < 0 || index >= FE_COUNT(kPositions))
{
return "";
}
return kPositions[index].key;
}
int
RPL4FrontEnd_GetTeamIndex()
{
return gHavePersist ? gPersistSelection[GroupTeam] : 0;
}
void
RPL4FrontEnd_SetTeamIndex(int index)
{
if (index < 0 || index >= FE_COUNT(kTeams))
{
return;
}
gPersistSelection[GroupTeam] = index;
gHavePersist = True;
}
int
RPL4FrontEnd_GetPositionIndex()
{
return gHavePersist ? gPersistSelection[GroupPosition] : 0;
}
void
RPL4FrontEnd_SetPositionIndex(int index)
{
if (index < 0 || index >= FE_COUNT(kPositions))
{
return;
}
gPersistSelection[GroupPosition] = index;
gHavePersist = True;
}
Logical
RPL4FrontEnd_IsFootballSelected()
{
return gHavePersist && IsFootball(gPersistSelection);
}
void
RPL4FrontEnd_SetHostedPilots(
const char *owner_address,
+36
View File
@@ -50,6 +50,39 @@ int
void
RPL4FrontEnd_GetLoadout(char *vehicle, char *color, char *badge);
//---------------------------------------------------------------
// Football team / position: the catalogs, and this player's picks.
// The lobby publishes the picks as member data and lets members
// cycle them in the room; the host's egg builder honors them.
//---------------------------------------------------------------
int
RPL4FrontEnd_TeamCount();
const char *
RPL4FrontEnd_TeamName(int index);
const char *
RPL4FrontEnd_TeamKey(int index);
int
RPL4FrontEnd_PositionCount();
const char *
RPL4FrontEnd_PositionName(int index);
const char *
RPL4FrontEnd_PositionKey(int index);
int
RPL4FrontEnd_GetTeamIndex();
void
RPL4FrontEnd_SetTeamIndex(int index);
int
RPL4FrontEnd_GetPositionIndex();
void
RPL4FrontEnd_SetPositionIndex(int index);
// True when this player's setup menu has Football selected (the host's
// pick decides the mission; members use it to know what to show).
Logical
RPL4FrontEnd_IsFootballSelected();
// Hosted-race pilots fed by the Steam lobby: overrides the
// RP412HOSTPODS parsing with real personas and loadouts. owner_address
// is this pod's mesh IP (the FakeIP); count 0 clears the override.
@@ -60,6 +93,9 @@ struct FEHostedPilot
char vehicle[24];
char color[16];
char badge[24];
// football: the member's own picks, empty = assign one for them
char team[32]; // team key ("Red/Pink", ...)
char position[16]; // "runner" / "crusher" / "blocker"
};
void
RPL4FrontEnd_SetHostedPilots(
+117 -7
View File
@@ -45,6 +45,7 @@ namespace
int gLastGoNonce = 0; // launches we already answered
int gShownResultsNonce = 0; // score sheets we already displayed
const char kResultsKey[] = "res";
const char kScenarioKey[] = "sc";
// async call-result plumbing
Logical gCallDone = False;
@@ -120,6 +121,12 @@ namespace
}
}
Logical IsOwner()
{
return gInLobby &&
SteamMatchmaking()->GetLobbyOwner(gLobby) == SteamUser()->GetSteamID();
}
void PublishMemberData()
{
char value[64];
@@ -140,12 +147,30 @@ namespace
SteamMatchmaking()->SetLobbyMemberData(gLobby, "vh", vehicle);
SteamMatchmaking()->SetLobbyMemberData(gLobby, "cl", color);
SteamMatchmaking()->SetLobbyMemberData(gLobby, "bd", badge);
// 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()));
// the owner also publishes the scenario, so members know
// whether their team/position pick matters
if (IsOwner())
{
SteamMatchmaking()->SetLobbyData(gLobby, kScenarioKey,
RPL4FrontEnd_IsFootballSelected() ? "football" : "race");
}
}
Logical IsOwner()
Logical FootballLobby()
{
return gInLobby &&
SteamMatchmaking()->GetLobbyOwner(gLobby) == SteamUser()->GetSteamID();
if (!gInLobby)
{
return False;
}
const char *scenario = SteamMatchmaking()->GetLobbyData(gLobby, kScenarioKey);
return (scenario != NULL && strcmp(scenario, "football") == 0);
}
//---------------------------------------------------------------
@@ -162,6 +187,8 @@ namespace
char vehicle[24];
char color[16];
char badge[24];
char team[32]; // football pick
char position[16];
Logical published;
};
@@ -196,6 +223,12 @@ namespace
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);
member->published =
member->ip[0] != '\0' && member->consolePort > 0 && member->gamePort > 0;
}
@@ -241,6 +274,8 @@ namespace
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);
if (pods[0] != '\0')
{
@@ -274,8 +309,11 @@ namespace
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;
@@ -303,8 +341,9 @@ namespace
RECT title = client;
title.top = client.bottom / 28;
title.bottom = title.top + client.bottom / 10;
DrawTextA(mem, "STEAM RACE LOBBY", -1, &title,
DT_CENTER | DT_TOP | DT_SINGLELINE);
DrawTextA(mem,
FootballLobby() ? "STEAM FOOTBALL LOBBY" : "STEAM RACE LOBBY",
-1, &title, DT_CENTER | DT_TOP | DT_SINGLELINE);
SelectObject(mem, room->textFont);
int row_h = client.bottom / 16;
@@ -328,7 +367,25 @@ namespace
is_owner_row ? " [HOST]" : "");
DrawTextA(mem, text, -1, &row, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
if (member->vehicle[0] != '\0')
if (FootballLobby())
{
// football: the roster is a team sheet
if (member->team[0] != '\0')
{
const char *position_name = "?";
for (int i = 0; i < RPL4FrontEnd_PositionCount(); ++i)
{
if (strcmp(member->position, RPL4FrontEnd_PositionKey(i)) == 0)
{
position_name = RPL4FrontEnd_PositionName(i);
break;
}
}
sprintf(text, "%s - %s", member->team, position_name);
DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
}
}
else if (member->vehicle[0] != '\0')
{
sprintf(text, "%s / %s", member->vehicle, member->color);
DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
@@ -347,11 +404,30 @@ namespace
-1, &hint, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
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 R A C E", -1, &room->launchRect,
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);
@@ -395,6 +471,18 @@ namespace
{
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;
@@ -465,6 +553,16 @@ namespace
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();
@@ -489,6 +587,18 @@ namespace
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)
{
SteamMatchmaking()->LeaveLobby(gLobby);
+10 -4
View File
@@ -166,10 +166,16 @@ library so a dev console tool and the game share one egg builder.
`[teams]` / `[team::X]` / `[pilots::X]` / `[teambitmap]` blocks in
RPFootballMission's layout. Colors are derived, not chosen: the
runner wears the team's runner color, everyone else the team color.
Multi-pod games alternate pilots between two teams and auto-assign
one runner per team (the host's own position pick is honored).
*Open:* lobby members cannot yet choose their own team/position —
the host's pick seeds a deterministic assignment.
**Every player picks their own team and position** (2026-07-24):
members publish their picks as lobby member data (`tm`/`ps`), the
room shows the roster as a team sheet and gives each member MY TEAM
/ MY POSITION buttons that cycle and republish live, and the owner
publishes the scenario (`sc`) so members know when those picks
matter. The egg builder honors every explicit pick and only fills
gaps: unpicked pilots are spread across the sides, a team with no
volunteer runner promotes its first *unpicked* member (explicit
picks are never overridden), and a second pilot claiming runner on
one team is moved to the line.
3. Results screen — **shipped**, on the cockpit displays, with the owner
publishing the score sheet to lobby members.
4. Steam model — lobby-owner-as-console **confirmed and verified** on