diff --git a/port/linux/README.md b/port/linux/README.md index 3c3f117735..769f96d43e 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -274,7 +274,9 @@ player's profile is chosen on the ADD PLAYER screen that opens (with any controller). With one gamepad, choose the lobby's ADD PLAYER button first: until then that gamepad shares controller 1 with the keyboard. Two players on one profile get different names from the host. A player's B in the lobby leaves the game alone, and the last player of the -computer leaves it for all of them. In the game, each player's pause menu +computer leaves it for all of them. When all the players are on the host's +computer, START NOW starts the game without another computer, and a team +game does not need a player on each team. In the game, each player's pause menu opens on their part of the screen, and its LEAVE GAME is theirs: their part of the screen stays until the game ends. A game under way shows its own screen before JOIN GAME: players join there the same way, START or ADD diff --git a/source/interface/ui_widget_game_data_input_functions.c b/source/interface/ui_widget_game_data_input_functions.c index b39fd464dd..2796b2a9d5 100644 --- a/source/interface/ui_widget_game_data_input_functions.c +++ b/source/interface/ui_widget_game_data_input_functions.c @@ -2922,7 +2922,7 @@ static void multiplayer_game_directions( if (server) { - /* port: one machine and one player may start (server_alone) */ + /* port: one machine and its players may start (server_players_on_one_machine) */ boolean waiting_for_machines = !network_game_is_splitscreen_local() && game && game->machine_count < 1; diff --git a/source/networking/network_server_manager.c b/source/networking/network_server_manager.c index 79681e5865..8f13ae30fb 100644 --- a/source/networking/network_server_manager.c +++ b/source/networking/network_server_manager.c @@ -3308,12 +3308,34 @@ boolean server_has_enough_machines( return has_enough_machines; } -/* port: a game's one player, with no one else in it yet, may start it: -a system link or internet game's host, or split screen's first player */ -static boolean server_alone( +/* port: TRUE if the game has at least one player and every player is on the +same machine; FALSE with no player. Such a game may start as a host alone +does: a system link or internet game's host, alone or with split screen +players. Machines are not counted, only players' machines: a machine that +has joined without a player does not stop the host's countdown +(server_ok_to_countdown) */ +static boolean server_players_on_one_machine( struct network_game_server *server) { - return server->game.player_count == 1; + short first_machine_index = NONE; + long player_index; + + for (player_index = 0; + player_index < MAXIMUM_NETWORK_PLAYER_COUNT; + player_index++) + { + struct network_player *player = &server->game.players[player_index]; + + if (!network_player_is_valid(player)) + continue; + + if (first_machine_index == NONE) + first_machine_index = (short)player->machine_index; + else if ((short)player->machine_index != first_machine_index) + return FALSE; + } + + return first_machine_index != NONE; } /* port: a machine joining or leaving is in the game before its player is. @@ -3324,8 +3346,8 @@ boolean server_ok_to_countdown( struct network_game_server *server) { if (server_has_enough_machines(server) && - (!server_needs_more_teams(server) || server_alone(server)) && - (server->game.player_count >= server->game.minimum_players || server_alone(server))) + (!server_needs_more_teams(server) || server_players_on_one_machine(server)) && + (server->game.player_count >= server->game.minimum_players || server_players_on_one_machine(server))) { return TRUE; } @@ -3486,7 +3508,7 @@ boolean network_game_server_game_can_start( match_assert(NETWORK_SERVER_MANAGER_FILE, 0x782, server); return server->state == 0 && - (server->game.player_count >= server->game.minimum_players || server_alone(server)); + (server->game.player_count >= server->game.minimum_players || server_players_on_one_machine(server)); } void network_game_server_pause_countdown( @@ -4003,7 +4025,7 @@ void network_game_server_update_countdown( { if (network_game_should_accept_remote_connections() == FALSE || network_game_server_get_client_machine_count(server) > 1 || - server_alone(server)) + server_players_on_one_machine(server)) { unsigned long countdown; diff --git a/tools/harness/tests/lobby_countdown.c b/tools/harness/tests/lobby_countdown.c new file mode 100644 index 0000000000..5cd25997a9 --- /dev/null +++ b/tools/harness/tests/lobby_countdown.c @@ -0,0 +1,181 @@ +/* +LOBBY_COUNTDOWN.C (test) + +The real pregame countdown of source/networking/network_server_manager.c: +whether the lobby's START NOW (the "faster" request, +_network_game_server_countdown_event_player_joined) starts the countdown, +for a host alone, a host with split screen players, and machines together. +test_lobby_countdown.py takes the constants, enums and timer structures +(config.inc) and the code (under_test.inc). +*/ + +#include "harness.h" + +#define NETWORK_SERVER_MANAGER_FILE "server" +#define MIN(a, b) ((a) < (b) ? (a) : (b)) + +#include "config.inc" + +struct network_player +{ + boolean valid; + signed char machine_index; + signed char team_index; +}; + +struct network_game_server_client_machine +{ + void *connection; + boolean joined; +}; + +struct network_game_server +{ + short state; + struct + { + short player_count; + short minimum_players; + struct + { + struct + { + boolean teams; + } universal_variant; + } variant; + struct network_player players[MAXIMUM_NETWORK_PLAYER_COUNT]; + } game; + struct network_game_server_client_machine client_machines[MAXIMUM_NETWORK_MACHINE_COUNT]; + struct network_game_server_countdown_state countdown_state; +}; + +static struct network_game_server server; +static int connection; + +static unsigned long system_milliseconds(void) { return 1000; } +static boolean network_game_should_accept_remote_connections(void) { return TRUE; } +static boolean network_game_is_splitscreen_local(void) { return FALSE; } +static boolean network_player_is_valid(struct network_player *player) { return player->valid; } +static boolean network_game_server_client_machine_is_joined_to_game( + struct network_game_server *s, + struct network_game_server_client_machine *machine) +{ + return machine->joined; +} +static void network_game_server_countdown_started(struct network_game_server *s) { } + +#include "under_test.inc" + +static void join_machine( + short machine_index) +{ + server.client_machines[machine_index].connection = &connection; + server.client_machines[machine_index].joined = TRUE; +} + +/* machine 0 is the host's; no players yet */ +static void lobby( + short machines) +{ + short index; + + memset(&server, 0, sizeof(server)); + server.state = _network_game_server_state_pregame; + server.game.minimum_players = 2; + for (index = 0; index < machines; index++) + join_machine(index); +} + +static void add_player( + short machine_index, + short team_index) +{ + struct network_player *player = &server.game.players[server.game.player_count++]; + + player->valid = TRUE; + player->machine_index = machine_index; + player->team_index = team_index; +} + +/* the lobby's START NOW */ +static void start_now(void) +{ + network_game_server_update_countdown(&server, _network_game_server_countdown_event_player_joined); +} + +int main(int argc, char **argv) +{ + char const *case_name = argc > 1 ? argv[1] : ""; + + CASE("split-screen-start-now") + { + lobby(1); + add_player(0, 0); + add_player(0, 1); + start_now(); + CHECK(server.countdown_state.active, "one machine's two players never start the countdown"); + CHECK(server.countdown_state.timer.time_remaining == NETWORK_GAME_COUNTDOWN_TIME, + "%ld ms to go, not a system link game's countdown", server.countdown_state.timer.time_remaining); + return 0; + } + CASE("alone-start-now") + { + lobby(1); + add_player(0, 0); + start_now(); + CHECK(server.countdown_state.active, "the host alone no longer starts the countdown"); + return 0; + } + CASE("empty-lobby-waits") + { + lobby(1); + CHECK(!server_ok_to_countdown(&server), "an empty lobby may count down"); + start_now(); + CHECK(!server.countdown_state.active, "an empty lobby started the countdown"); + return 0; + } + CASE("two-machines-start-now") + { + lobby(2); + add_player(0, 0); + add_player(1, 1); + start_now(); + CHECK(server.countdown_state.active, "two machines no longer start the countdown"); + return 0; + } + CASE("two-machines-team-game-needs-both-teams") + { + lobby(2); + server.game.variant.universal_variant.teams = TRUE; + add_player(0, 0); + add_player(1, 0); + CHECK(!server_ok_to_countdown(&server), "two machines' players of one team may count down"); + start_now(); + CHECK(!server.countdown_state.active, "two machines' players of one team started the countdown"); + return 0; + } + /* server_ok_to_countdown is checked each tick, and a failure stops the countdown */ + CASE("playerless-machine-keeps-countdown") + { + lobby(1); + add_player(0, 0); + start_now(); + CHECK(server.countdown_state.active, "the host alone no longer starts the countdown"); + join_machine(1); + CHECK(server_ok_to_countdown(&server), "a machine joining without a player stops the countdown"); + return 0; + } + /* intended: one machine skips the team rule, as a lone host does */ + CASE("split-screen-team-game-one-team") + { + lobby(1); + server.game.variant.universal_variant.teams = TRUE; + add_player(0, 0); + add_player(0, 0); + start_now(); + CHECK(server.countdown_state.active, "one machine's players of one team never start the countdown"); + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/test_lobby_countdown.py b/tools/harness/tests/test_lobby_countdown.py new file mode 100644 index 0000000000..0bd69bccfc --- /dev/null +++ b/tools/harness/tests/test_lobby_countdown.py @@ -0,0 +1,84 @@ +"""The real pregame countdown of network_server_manager.c: the lobby's START NOW starts it for a host alone, a host with +split screen players and machines together, and a machine joining without a player does not stop it.""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, constant, enum_with, function, mutated, read, run, structure # noqa: E402 + +CASES = [ + "split-screen-start-now", "alone-start-now", "empty-lobby-waits", "two-machines-start-now", + "two-machines-team-game-needs-both-teams", "playerless-machine-keeps-countdown", + "split-screen-team-game-one-team", +] +FUNCTIONS = ( + "countdown_timer_update", "countdown_timer_get_time_remaining", "countdown_timer_increment", + "countdown_timer_decrement", "countdown_timer_set_time_remaining", + "network_game_server_get_client_machine_count", "server_needs_more_teams", + "server_has_enough_machines", "server_players_on_one_machine", "server_ok_to_countdown", + "network_game_server_update_countdown", +) +CONSTANTS = ( + "MAXIMUM_NETWORK_MACHINE_COUNT", "MAXIMUM_NETWORK_PLAYER_COUNT", "NUMBER_OF_MULTIPLAYER_TEAMS", + "NETWORK_GAME_COUNTDOWN_TIME", "NETWORK_GAME_SPLITSCREEN_COUNTDOWN_TIME", + "NETWORK_GAME_COUNTDOWN_ADJUSTMENT", "NETWORK_GAME_MINIMUM_COUNTDOWN_TIME", +) + +# a fault in network_server_manager.c, the function it is in, and the case that must catch it +NEGATIVE_CONTROLS = { + "one-player-only": ( + ("else if ((short)player->machine_index != first_machine_index)", "else"), + "server_players_on_one_machine", "split-screen-start-now", + ), + "machines-ignored": ( + ("else if ((short)player->machine_index != first_machine_index)", "else if (0)"), + "server_players_on_one_machine", "two-machines-team-game-needs-both-teams", + ), + "no-player-counts": ( + ("return first_machine_index != NONE;", "return TRUE;"), + "server_players_on_one_machine", "empty-lobby-waits", + ), + "joined-machines-counted": ( + ( + "return first_machine_index != NONE;", + "return first_machine_index != NONE && network_game_server_get_client_machine_count(server) == 1;", + ), + "server_players_on_one_machine", "playerless-machine-keeps-countdown", + ), +} + + +def generated(fault=None, faulty_function=None): + source = read("source/networking/network_server_manager.c") + # (the machine and player counts are halo_port_limits.h's, which constant() does not follow into) + with_limits = source + read("port/linux/include/halo_port_limits.h") + config = "" + for name in CONSTANTS: + config += f"#define {name} {constant(with_limits, name)}\n" + for member in ("_network_game_server_state_pregame", "_network_game_server_countdown_event_player_left"): + config += enum_with(source, member) + "\n" + for name in ("countdown_timer", "network_game_server_countdown_state"): + config += structure(source, name) + "\n" + text = "" + for name in FUNCTIONS: + code = function(source, name) + if fault and name == faulty_function: + code = mutated(code, *fault) + text += code + "\n" + return (("config.inc", config), ("under_test.inc", text)) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("lobby_countdown", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("lobby_countdown", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"network_server_manager.c with a fault ({control}) passed '{case}': the test cannot see it"