diff --git a/action/lrcon.cfg.example b/action/lrcon.cfg.example new file mode 100644 index 000000000..0adf2dce5 --- /dev/null +++ b/action/lrcon.cfg.example @@ -0,0 +1,70 @@ +// LRCON Configuration Example +// Copy to lrcon.cfg and customize for your server +// +// LRCON provides limited remote console access allowing players to +// claim temporary admin rights and execute restricted server commands + +[settings] +// Enable or disable LRCON on this server +enabled 1 + +// Quit the server when the last player leaves +// Set to 1 to enable, 0 to disable (default) +quit_on_empty 0 + +[allowed_cvars] +// List of cvars that can be queried and modified via lrcon +// One cvar per line - only whitelisted cvars can be changed +// +// WARNING: Do NOT whitelist sensitive cvars here. In particular, NEVER add: +// rcon_password - full server takeover +// password - lock all other players out +// sv_load_ent - bypass entity sandboxing +// sys_forcegamelib - load arbitrary game DLLs +// sys_* - system-level cvars +// Whitelist only gameplay-tuning cvars. +// +// Examples: +timelimit +fraglimit +teamdm +ctf +maxclients +hostname +dmflags +roundlimit +matchmode +teamplay +g_select_empty +sv_gravity +sv_fps +sv_antilag + +[allowed_stuffcmds] +// Comma-delimited list of commands that may be sent to clients via +// `lrcon stuffcmd `. +// +// SECURITY: If this section is empty or missing, all stuffcmds are DENIED. +// Without an allowlist, a claimer can stuffcmd `disconnect`, `quit`, +// arbitrary `bind`s, or chain commands — effectively RCE on every client. +// +// Recommended baseline allowlist (uncomment to enable): +// disconnect, reconnect, say, say_team, record, stoprecord + +[modes] +// Server configuration modes - allows players to switch configs quickly +// Format: mode_name|exec .cfg +// +// SECURITY: mode commands MUST be of the form `exec .cfg`. +// Filenames may only contain alphanumeric characters, '_', '-', '/', '.'. +// No '..' path traversal, no absolute paths, no command chaining. +// Malformed entries are rejected at startup with a warning. +// +// Examples: +// +// teamdm|exec cfg/teamdm.cfg +// ctf|exec cfg/ctf.cfg +// ffa|exec cfg/ffa.cfg +// duel|exec cfg/1v1.cfg +// instagib|exec cfg/instagib.cfg +// campmode|exec cfg/campmode.cfg diff --git a/doc/action.md b/doc/action.md index cef5c473c..97609d603 100644 --- a/doc/action.md +++ b/doc/action.md @@ -268,6 +268,15 @@ The forfeit system provides two ways to end a match early: captain-initiated for **Abandonment Forfeit**: When enabled, if all teams have zero players during a match in progress (at least one round played or any team has a score), the abandon timer begins counting down. If a player reconnects and joins a team before the timer expires, it resets. Warnings are printed at 30 seconds, 10 seconds, and a final 5-second countdown. If the timer expires, the match ends with no score awarded. +#### Score Carryover + +In multi-map matches (e.g., best-of-two), team scores are normally reset when the map changes. Enabling `mm_carryover` preserves team scores from the first map into the second map, so the final scoreboard reflects the cumulative result across both maps. + +- Server settings: + - `mm_carryover [0/1]` - Carry over team scores from map 1 to map 2 (default: 0). Requires matchmode to be enabled. Supports `use_3teams`. + +After the second map concludes, the carryover is automatically cleared. Stat logging subtracts the carried-over scores so that per-map statistics remain accurate. + #### Timeout Settings Timeouts allow teams to pause the match for a strategic break. The following cvars control timeout behavior: diff --git a/src/action/a_cmds.c b/src/action/a_cmds.c index f66655a54..06fdff4c7 100644 --- a/src/action/a_cmds.c +++ b/src/action/a_cmds.c @@ -1232,6 +1232,7 @@ void RemoveSpaces(char *s) void Cmd_AutoRecord_f(edict_t * ent) { char rec_date[20], recstr[MAX_QPATH]; + char *p; time_t clock; time( &clock ); @@ -1249,6 +1250,16 @@ void Cmd_AutoRecord_f(edict_t * ent) Q_snprintf(recstr, sizeof(recstr), "%s-%s", rec_date, level.mapname); } + /* Belt-and-suspenders: even though teamname intake sanitizes, scrub anything + * that could break out of the quoted stuffcmd arg (recstr also includes + * level.mapname which is engine-controlled but cheap to harden). */ + for (p = recstr; *p; p++) { + if (*p == '"' || *p == '\\' || *p == '\n' || *p == '\r' || + *p == ';' || *p == '$' || (unsigned char)*p < 0x20) { + *p = '_'; + } + } + stuffcmd(ent, va("record \"%s\"\n", recstr)); } diff --git a/src/action/a_esp.h b/src/action/a_esp.h index 501e32d42..53f13cc5b 100644 --- a/src/action/a_esp.h +++ b/src/action/a_esp.h @@ -1,10 +1,6 @@ // This is set to 1 if either atl or etv are 1 extern cvar_t *esp; -// Discrete game modes -extern cvar_t *atl; -extern cvar_t *etv; - #define IS_LEADER(ent) (teams[(ent)->client->resp.team].leader == (ent)) #define HAVE_LEADER(teamNum) (teams[(teamNum)].leader) #define MAX_ESP_STRLEN 32 diff --git a/src/action/a_game.c b/src/action/a_game.c index 64aeff1db..7e009f86f 100644 --- a/src/action/a_game.c +++ b/src/action/a_game.c @@ -1659,13 +1659,23 @@ void ReadLrconConfig(void) game.lrcon_config.quit_on_empty = 0; game.lrcon_config.allowed_cvars_count = 0; game.lrcon_config.modes_count = 0; + game.lrcon_config.allowed_stuffcmds_count = 0; - // Get config filename from cvar + // Get config filename from cvar. + // Validate value: must be a plain filename within the action/ directory. + // Without this, lrcon_config "../../../etc/crontab" would open arbitrary + // filesystem paths. Also replaces unbounded sprintf with Q_snprintf. lrcon_config_cvar = gi.cvar("lrcon_config", "lrcon.cfg", 0); - if (lrcon_config_cvar->string && *(lrcon_config_cvar->string)) - sprintf(cfgpath, "%s/%s", GAMEVERSION, lrcon_config_cvar->string); - else - sprintf(cfgpath, "%s/%s", GAMEVERSION, "lrcon.cfg"); + { + const char *name = (lrcon_config_cvar->string && *lrcon_config_cvar->string) + ? lrcon_config_cvar->string : "lrcon.cfg"; + if (strstr(name, "..") || strchr(name, '/') || strchr(name, '\\') || + strchr(name, ':')) { + gi.dprintf("LRCON: refusing lrcon_config '%s' — must be a plain filename within action/\n", name); + return; + } + Q_snprintf(cfgpath, sizeof(cfgpath), "%s/%s", GAMEVERSION, name); + } // Try to open config file config_file = fopen(cfgpath, "r"); @@ -1738,15 +1748,78 @@ void ReadLrconConfig(void) game.lrcon_config.allowed_cvars[game.lrcon_config.allowed_cvars_count]); game.lrcon_config.allowed_cvars_count++; } + } else if (!strcmp(reading_section, "allowed_stuffcmds")) { + // Comma-delimited list of commands allowed via `lrcon stuffcmd`. + // Why: without an allowlist, a claimer can stuffcmd `disconnect`, + // `quit`, arbitrary `bind`s, or chain commands via ';' — effectively + // RCE on every connected client. + char *tok, *saveptr_buf = buf; + while ((tok = strtok(saveptr_buf, ", \t")) != NULL) { + saveptr_buf = NULL; + if (game.lrcon_config.allowed_stuffcmds_count >= MAX_LRCON_STUFFCMDS) + break; + if (!*tok) + continue; + Q_strncpyz(game.lrcon_config.allowed_stuffcmds[game.lrcon_config.allowed_stuffcmds_count], + tok, sizeof(game.lrcon_config.allowed_stuffcmds[0])); + gi.dprintf("LRCON: allowed stuffcmd %d = %s\n", + game.lrcon_config.allowed_stuffcmds_count, + game.lrcon_config.allowed_stuffcmds[game.lrcon_config.allowed_stuffcmds_count]); + game.lrcon_config.allowed_stuffcmds_count++; + } } else if (!strcmp(reading_section, "modes")) { - // Format: name|command + // Format: name|exec + // Why: mode command is passed verbatim to AddCommandString. + // Without restriction, an operator (or compromised config) can + // embed arbitrary commands via ';'. Restrict to strict + // "exec .cfg" form. char *pipe = strchr(buf, '|'); if (pipe != NULL && game.lrcon_config.modes_count < MAX_LRCON_MODES) { + const char *cmd, *fname; + size_t flen; + qboolean valid = true; + *pipe = 0; + cmd = pipe + 1; + + // Must begin with "exec " + if (Q_strncasecmp(cmd, "exec ", 5) != 0) { + gi.dprintf("LRCON: rejecting mode '%s' — command must start with 'exec '\n", buf); + valid = false; + } + + if (valid) { + fname = cmd + 5; + while (*fname == ' ') fname++; + flen = strlen(fname); + + // Filename rules: non-empty, ends in .cfg, no traversal, + // only Q_ispath() chars plus '/' and '.' + if (flen < 5 || strcmp(fname + flen - 4, ".cfg") != 0) { + gi.dprintf("LRCON: rejecting mode '%s' — filename must end in .cfg\n", buf); + valid = false; + } else if (strstr(fname, "..") || fname[0] == '/' || fname[0] == '\\') { + gi.dprintf("LRCON: rejecting mode '%s' — filename has traversal or absolute path\n", buf); + valid = false; + } else { + const char *p; + for (p = fname; *p; p++) { + if (!(Q_ispath(*p) || *p == '/' || *p == '.')) { + gi.dprintf("LRCON: rejecting mode '%s' — filename has disallowed char\n", buf); + valid = false; + break; + } + } + } + } + + if (!valid) + continue; + Q_strncpyz(game.lrcon_config.modes[game.lrcon_config.modes_count].name, buf, sizeof(game.lrcon_config.modes[0].name)); Q_strncpyz(game.lrcon_config.modes[game.lrcon_config.modes_count].command, - pipe + 1, sizeof(game.lrcon_config.modes[0].command)); + cmd, sizeof(game.lrcon_config.modes[0].command)); gi.dprintf("LRCON: mode %d = %s -> %s\n", game.lrcon_config.modes_count, game.lrcon_config.modes[game.lrcon_config.modes_count].name, diff --git a/src/action/a_match.c b/src/action/a_match.c index ab334910c..49f0acad6 100644 --- a/src/action/a_match.c +++ b/src/action/a_match.c @@ -92,6 +92,17 @@ void SendScores(void) // Stats: Reset roundNum game.roundNum = 0; // Stats end + + // Clear carryover scores after map 2 is done. + // Keep carryover_active=true so ExitLevel() won't re-save scores. + // SpawnEntities() on map 3 will reset carryover_active when it finds no scores. + if (game.carryover_active) + { + int i; + for(i = TEAM1; i < TEAM_TOP; i++) + game.carryover_scores[i] = 0; + gi.dprintf("Matchmode carryover: cleared after map 2\n"); + } } void Cmd_Sub_f(edict_t * ent) @@ -410,6 +421,13 @@ qboolean CheckAbandon(void) if (!matchmode->value || !use_forfeit->value) return false; + /* Treat forfeit_abandon_time <= 0 as "abandonment detection disabled". + * Without this guard, abandonFrames becomes 0 in the registration path, + * the `!level.abandonFrames` branch fires every server tick, and the + * announcement spams the log forever. */ + if (forfeit_abandon_time->value <= 0) + return false; + if (!team_game_going) return false; @@ -484,6 +502,22 @@ qboolean CheckAbandon(void) return false; } +/* + * Replace shell/stuffcmd-dangerous characters with '_' in-place. + * Why: team names are echoed into stuffcmd'd console commands (autorecord, + * etc.). An unescaped '"', ';', '\n', or '$' lets a captain inject commands + * into every other player's console. + */ +static void sanitize_command_arg(char *s) +{ + for (; *s; s++) { + if (*s == '"' || *s == '\\' || *s == '\n' || *s == '\r' || + *s == ';' || *s == '$' || (unsigned char)*s < 0x20) { + *s = '_'; + } + } +} + void Cmd_Teamname_f(edict_t * ent) { int i, argc, teamNum; @@ -544,6 +578,11 @@ void Cmd_Teamname_f(edict_t * ent) temp[18] = 0; } + if (!temp[0]) + strcpy( temp, "noname" ); + + sanitize_command_arg(temp); + if (!temp[0]) strcpy( temp, "noname" ); @@ -645,7 +684,10 @@ void Cmd_Teamnone_f(edict_t *ent) return; } - if (gi.argc() < 1) { + /* gi.argc() always returns at least 1 (the command name itself), so the + * previous `< 1` guard was dead code — missing-arg silently fell through + * with playernum=0 from atoi(""). */ + if (gi.argc() < 2) { gi.cprintf(ent, PRINT_HIGH, "You need to provide a playernum for this command\nUse 'playerlist' to get a list of playernums\n"); return; } @@ -897,7 +939,10 @@ void Cmd_CallTimeout_f(edict_t * ent) return; } - if (level.matchTime >= timelimit->value * 60) { + /* Skip the last-round guard when timelimit is unlimited (0). Otherwise + * `matchTime >= 0` is always true and timeouts are blocked permanently + * on unlimited-time servers. */ + if (timelimit->value > 0 && level.matchTime >= timelimit->value * 60) { gi.cprintf(ent, PRINT_HIGH, "You cannot call for a timeout on the last round of the match\n"); return; } diff --git a/src/action/g_local.h b/src/action/g_local.h index 4cf5f224e..2f335f6fa 100644 --- a/src/action/g_local.h +++ b/src/action/g_local.h @@ -793,6 +793,7 @@ typedef struct precache_s { #define MAX_LRCON_CVARS 32 #define MAX_LRCON_MODES 16 +#define MAX_LRCON_STUFFCMDS 16 /* LRCON state - tracks current server claim */ typedef struct { @@ -817,6 +818,8 @@ typedef struct { char allowed_cvars[MAX_LRCON_CVARS][64]; /* Whitelisted cvar names */ int modes_count; /* Number of available modes */ lrcon_mode_t modes[MAX_LRCON_MODES]; /* Available server modes */ + int allowed_stuffcmds_count; /* Number of allowlisted client stuffcmds */ + char allowed_stuffcmds[MAX_LRCON_STUFFCMDS][32]; /* Allowlisted commands for `lrcon stuffcmd` */ } lrcon_config_t; // @@ -879,6 +882,10 @@ typedef struct // LRCON configuration lrcon_config_t lrcon_config; + + // Matchmode carryover scores (persist across map changes) + int carryover_scores[TEAM_TOP]; + qboolean carryover_active; // true if we're on map 2 with carried-over scores } game_locals_t; @@ -972,6 +979,7 @@ typedef struct int timeoutFrames; float matchTime; float emptyTime; + float quit_empty_time; // LRCON quit_on_empty: level.time when server first emptied, or -1 if not empty. Separate from emptyTime which empty_rotate uses as an accumulator. int abandonFrames; // Countdown for abandon forfeit int weapon_sound_framenum; int pic_teamplay_timer_icon; @@ -1392,8 +1400,6 @@ extern cvar_t *medkit_value; // BEGIN AQ2 ETE extern cvar_t *esp; // Enable or disable Espionage mode -extern cvar_t *atl; // Enable or disable Assassinate the Leader mode (do not set this manually) -extern cvar_t *etv; // Enable or disable Escort the VIP mode (do not set this manually) extern cvar_t *esp_atl; // Prefer ATL mode even if ETV mode is available extern cvar_t *esp_punish; // Enable or disable punishment for losing the around extern cvar_t *esp_etv_halftime; // Enable or disable halftime in ETV mode @@ -1451,6 +1457,7 @@ extern cvar_t *bots; // If bots are enabled and in the server // 2026 extern cvar_t *use_buggy_ent_hitbox; +extern cvar_t *mm_carryover; // Carry over team scores across maps in matchmode #ifdef AQTION_EXTENSION extern int (*engine_Client_GetVersion)(edict_t *ent); diff --git a/src/action/g_lrcon.c b/src/action/g_lrcon.c index dde1067da..d493f7d9a 100644 --- a/src/action/g_lrcon.c +++ b/src/action/g_lrcon.c @@ -21,6 +21,34 @@ extern cvar_t *lrcon_claimer_name; extern cvar_t *lrcon_claimer_ip; extern int dosoft; +/* + * lrcon_valid_mapname + * + * Returns true if mapname is safe to pass to the engine's map-change path. + * Restricts to alnum/underscore/hyphen — rejects path traversal (..), + * separators (/ \), command separators (; &), and quote/escape chars. + * Why: lrcon map / softmap embeds the name into a downstream AddCommandString + * path where ';' is a command separator; without validation a claimer can + * chain arbitrary server commands. + */ +static qboolean lrcon_valid_mapname(const char *s) +{ + size_t len; + + if (!s || !*s) + return false; + + len = strlen(s); + if (len >= MAX_QPATH) + return false; + + for (; *s; s++) { + if (!Q_ispath(*s)) + return false; + } + return true; +} + /* * Lrcon_CheckClaimer * @@ -237,6 +265,12 @@ void Lrcon_Map(edict_t *ent) mapname = gi.argv(2); + if (!lrcon_valid_mapname(mapname)) { + gi.cprintf(ent, PRINT_HIGH, + "Invalid mapname. Use alphanumeric, underscore, hyphen only.\n"); + return; + } + gi.bprintf(PRINT_HIGH, "%s is changing map to %s\n", ent->client->pers.netname, mapname); @@ -263,6 +297,12 @@ void Lrcon_Softmap(edict_t *ent) mapname = gi.argv(2); + if (!lrcon_valid_mapname(mapname)) { + gi.cprintf(ent, PRINT_HIGH, + "Invalid mapname. Use alphanumeric, underscore, hyphen only.\n"); + return; + } + gi.bprintf(PRINT_HIGH, "%s is soft-changing map to %s\n", ent->client->pers.netname, mapname); @@ -325,8 +365,12 @@ void Lrcon_Stuffcmd(edict_t *ent) const char *target_arg; const char *command; const char *cmd_start; + const char *p; + char cmd_name[32]; edict_t *target; int i, skip_count; + size_t name_len; + qboolean allowed; if (!Lrcon_CheckClaimer(ent)) return; @@ -346,6 +390,48 @@ void Lrcon_Stuffcmd(edict_t *ent) cmd_start++; } + /* Reject command-chaining or substitution characters anywhere in the + * payload. Without this, the allowlist below can be bypassed via + * `; `. */ + for (p = cmd_start; *p; p++) { + if (*p == ';' || *p == '\n' || *p == '\r' || *p == '$') { + gi.cprintf(ent, PRINT_HIGH, + "lrcon stuffcmd: command contains disallowed character\n"); + return; + } + } + + /* Extract the command name (first whitespace-delimited token) and check + * it against the operator-configured allowlist. The allowlist is loaded + * from the [allowed_stuffcmds] section in lrcon.cfg as a comma-delimited + * list. If empty, all stuffcmds are denied — secure-by-default. */ + for (name_len = 0; cmd_start[name_len] && cmd_start[name_len] != ' ' && + cmd_start[name_len] != '\t' && name_len < sizeof(cmd_name) - 1; + name_len++) { + cmd_name[name_len] = cmd_start[name_len]; + } + cmd_name[name_len] = '\0'; + + if (!cmd_name[0]) { + gi.cprintf(ent, PRINT_HIGH, "lrcon stuffcmd: empty command\n"); + return; + } + + allowed = false; + for (i = 0; i < game.lrcon_config.allowed_stuffcmds_count; i++) { + if (!Q_stricmp(cmd_name, game.lrcon_config.allowed_stuffcmds[i])) { + allowed = true; + break; + } + } + + if (!allowed) { + gi.cprintf(ent, PRINT_HIGH, + "lrcon stuffcmd: '%s' is not in [allowed_stuffcmds]\n", + cmd_name); + return; + } + if (!Q_stricmp(target_arg, "all")) { /* Send to all clients */ for (i = 0; i < game.maxclients; i++) { @@ -353,16 +439,16 @@ void Lrcon_Stuffcmd(edict_t *ent) if (!target->inuse || !target->client) continue; stuffcmd(target, va("%s\n", cmd_start)); } - gi.bprintf(PRINT_HIGH, "%s sent command to all players\n", - ent->client->pers.netname); + gi.bprintf(PRINT_HIGH, "%s sent '%s' to all players\n", + ent->client->pers.netname, cmd_name); } else { /* Send to specific client */ target = LookupPlayer(ent, target_arg, true, false); if (!target) return; stuffcmd(target, va("%s\n", cmd_start)); - gi.bprintf(PRINT_HIGH, "%s sent command to %s\n", - ent->client->pers.netname, target->client->pers.netname); + gi.bprintf(PRINT_HIGH, "%s sent '%s' to %s\n", + ent->client->pers.netname, cmd_name, target->client->pers.netname); } } diff --git a/src/action/g_main.c b/src/action/g_main.c index 41546d2cc..be5bc212b 100644 --- a/src/action/g_main.c +++ b/src/action/g_main.c @@ -533,8 +533,6 @@ cvar_t *jump; // jumping mod // BEGIN AQ2 ETE cvar_t *esp; -cvar_t *atl; -cvar_t *etv; cvar_t *esp_atl; cvar_t *esp_punish; cvar_t *esp_etv_halftime; @@ -605,6 +603,7 @@ cvar_t *bots; // If bots are enabled and in the server // 2026 cvar_t *use_buggy_ent_hitbox; // Enables classic dead entity hitbox +cvar_t *mm_carryover; // Carry over team scores across maps in matchmode #ifdef AQTION_EXTENSION cvar_t *use_newirvision; @@ -1254,6 +1253,17 @@ void ExitLevel (void) // clear some things before going to next level if (teamplay->value) { + // Save scores for carryover if enabled in matchmode + if (mm_carryover->value && matchmode->value && !game.carryover_active) + { + for(i=TEAM1; i 5.0` to fire after one frame when a + // player left a long-running server (level.time was already large but + // emptyTime had just incremented from 0). Sentinel -1.0f means "not + // currently empty" so we can distinguish from level.time == 0 at start. if (game.lrcon_config.quit_on_empty) { if (empty) { - if (level.emptyTime == 0) { - level.emptyTime = level.time; + if (level.quit_empty_time < 0) { + level.quit_empty_time = level.time; gi.dprintf("LRCON: Server empty, will quit in 5 seconds\n"); - } else if (level.time - level.emptyTime > 5.0) { + } else if (level.time - level.quit_empty_time > 5.0f) { gi.dprintf("LRCON: Quitting server (empty for 5+ seconds)\n"); gi.AddCommandString("quit\n"); } } else { - level.emptyTime = 0; + level.quit_empty_time = -1.0f; } } diff --git a/src/action/g_save.c b/src/action/g_save.c index c6ec2d321..a3801e12d 100644 --- a/src/action/g_save.c +++ b/src/action/g_save.c @@ -490,7 +490,7 @@ void InitGame( void ) mm_allowlock = gi.cvar( "mm_allowlock", "1", CVAR_LATCH ); mm_pausecount = gi.cvar( "mm_allowcount", "3", CVAR_LATCH ); mm_pausetime = gi.cvar( "mm_pausetime", "2", CVAR_LATCH ); - mm_timeoutcount = gi.cvar( "mm_timeoutcount", "1", CVAR_LATCH ); // 1 timeout + mm_timeoutcount = gi.cvar( "mm_timeoutcount", "2", CVAR_LATCH ); // 2 timeouts per team per match mm_timeouttime = gi.cvar( "mm_timeouttime", "60", CVAR_LATCH ); // 60 seconds use_forfeit = gi.cvar( "use_forfeit", "0", 0 ); // Enable forfeit command forfeit_abandon_time = gi.cvar( "forfeit_abandon_time", "60", 0 ); // Abandon timer in seconds @@ -618,7 +618,7 @@ void InitGame( void ) esp_matchmode = gi.cvar("esp_matchmode", "0", 0); esp_respawn_uvtime = gi.cvar("esp_respawn_uvtime", "10", 0); if (esp_respawn_uvtime->value > 20) { - gi.dprintf("esp_respawn_uvtime was set too high, setting to 2 seconds\n"); + gi.dprintf("esp_respawn_uvtime was set too high, setting to 20 seconds\n"); gi.cvar_forceset("esp_respawn_uvtime", "20"); } esp_debug = gi.cvar("esp_debug", "0", 0); // Set to 1 to enable debug messages for Espionage @@ -695,6 +695,7 @@ void InitGame( void ) // 2026 use_buggy_ent_hitbox = gi.cvar("use_buggy_ent_hitbox", "1", 0); + mm_carryover = gi.cvar("mm_carryover", "0", CVAR_LATCH); // new AQtion Extension cvars #ifdef AQTION_EXTENSION diff --git a/src/action/g_spawn.c b/src/action/g_spawn.c index 5f7620892..94683a5d9 100644 --- a/src/action/g_spawn.c +++ b/src/action/g_spawn.c @@ -1217,6 +1217,30 @@ void SpawnEntities (const char *mapname, const char *entities, const char *spawn gi.cvar_forceset(teams[i].teamscore->name, "0"); } + // Restore carried-over scores from previous map in matchmode + if (mm_carryover->value && matchmode->value && + (game.carryover_scores[TEAM1] || game.carryover_scores[TEAM2] || game.carryover_scores[TEAM3])) + { + for(i = TEAM1; i < TEAM_TOP; i++) + { + teams[i].score = game.carryover_scores[i]; + if (teams[i].teamscore) { + char val[16]; + Q_snprintf(val, sizeof(val), "%d", game.carryover_scores[i]); + gi.cvar_forceset(teams[i].teamscore->name, val); + } + } + game.carryover_active = true; + gi.dprintf("Matchmode carryover: restored scores t1=%d t2=%d t3=%d\n", + game.carryover_scores[TEAM1], + game.carryover_scores[TEAM2], + game.carryover_scores[TEAM3]); + } + else + { + game.carryover_active = false; + } + day_cycle_at = 0; team_round_going = team_game_going = team_round_countdown = 0; lights_camera_action = holding_on_tie_check = 0; @@ -1556,6 +1580,10 @@ void SpawnEntities (const char *mapname, const char *entities, const char *spawn memset(&level, 0, sizeof (level)); memset(g_edicts, 0, game.maxentities * sizeof (g_edicts[0])); + // quit_empty_time uses -1 as the "not currently empty" sentinel (0 is a + // valid level.time at start of map). + level.quit_empty_time = -1.0f; + Q_strncpyz(level.mapname, mapname, sizeof(level.mapname)); Q_strncpyz(game.spawnpoint, spawnpoint, sizeof(game.spawnpoint)); diff --git a/src/action/p_client.c b/src/action/p_client.c index df182179f..1b27a2724 100644 --- a/src/action/p_client.c +++ b/src/action/p_client.c @@ -3849,11 +3849,14 @@ qboolean ClientConnect(edict_t * ent, char *userinfo) IRC_printf(IRC_T_SERVER, "%n@%s connected", value, ipaddr_buf); } - // LRCON: Check if reconnecting claimer and restore claim + // LRCON: Check if reconnecting claimer and restore claim. + // Use Q_stricmp to match Lrcon_CheckClaimer's case-insensitive compare; + // otherwise a claimer reconnecting as "admin" (was "Admin") passes the + // permission check at command time but silently fails restore here. value = Info_ValueForKey(userinfo, "name"); if (game.lrcon_config.enabled && lrcon_claimer_name->string && *lrcon_claimer_name->string && - !strcmp(lrcon_claimer_name->string, value) && - !strcmp(lrcon_claimer_ip->string, ipaddr_buf)) { + !Q_stricmp(lrcon_claimer_name->string, value) && + !Q_stricmp(lrcon_claimer_ip->string, ipaddr_buf)) { level.lrcon.claimed = true; Q_strncpyz(level.lrcon.claimer_name, lrcon_claimer_name->string, sizeof(level.lrcon.claimer_name)); @@ -3913,7 +3916,12 @@ void ClientDisconnect(edict_t * ent) if (!ent->client) return; - if (esp->value && matchmode->value) { + /* Only fire the captain-disconnect broadcast for actual captains. + * Previously this ran for every disconnect from an esp+matchmode game, + * including spectators (resp.team == NOTEAM), which would read + * teams[NOTEAM].name — empty/stale slot. */ + if (esp->value && matchmode->value && + ent->client->resp.team != NOTEAM && IS_CAPTAIN(ent)) { char tempmsg[128]; // We have to kill him first before he is removed as captain/leader killPlayer(ent, false); diff --git a/src/action/tng_stats.c b/src/action/tng_stats.c index d14dd766a..4a0f23029 100644 --- a/src/action/tng_stats.c +++ b/src/action/tng_stats.c @@ -1324,6 +1324,14 @@ void LogMatch(void) int t3 = teams[TEAM3].score; eventtime = (int)time(NULL); + // Subtract carried-over scores so stats reflect only this map's results + if (game.carryover_active) + { + t1 -= game.carryover_scores[TEAM1]; + t2 -= game.carryover_scores[TEAM2]; + t3 -= game.carryover_scores[TEAM3]; + } + // Check if there's an AI bot in the game, if so, do nothing if (game.ai_ent_found) { return; diff --git a/src/server/init.c b/src/server/init.c index 818145ee3..432d64593 100644 --- a/src/server/init.c +++ b/src/server/init.c @@ -481,6 +481,11 @@ void SV_InitGame(unsigned mvd_spawn) SV_MvdPostInit(); } + // Reset bot client slots on game (re)initialization. Cold-start gets + // zeroed bot_clients[] via BSS, but SV_InitGame can be called on + // restart/map-change where in-progress state could otherwise persist. + SV_BotInit(); + if (svs.csr.extended && IS_NEW_GAME_API) PmoveEnableExt(&svs.pmp); diff --git a/src/server/main.c b/src/server/main.c index 8031a6bd0..3e35abce8 100644 --- a/src/server/main.c +++ b/src/server/main.c @@ -914,8 +914,14 @@ static void SVC_RulesExt(void) return; } - // Check if we need to rebuild the cache - if (svs.realtime - rulesext_cache.timestamp > RULESEXT_CACHE_TIME) { + // Check if we need to rebuild the cache. + // Force a build on first use (chunk_count == 0): otherwise during the + // first RULESEXT_CACHE_TIME (~5s) of uptime, svs.realtime and timestamp + // are both 0 so the staleness check is false and the cache stays empty, + // leaving all chunk replies empty until the threshold passes. + // Mirrors the SVC_StatusExt build-condition. + if (!rulesext_cache.chunk_count || + svs.realtime - rulesext_cache.timestamp > RULESEXT_CACHE_TIME) { len = SV_BuildExtendedRules(rulesext_cache.data, sizeof(rulesext_cache.data)); rulesext_cache.total_size = len; rulesext_cache.chunk_count = (len + RULESEXT_CHUNK_SIZE - 1) / RULESEXT_CHUNK_SIZE; diff --git a/subprojects/packagefiles/.DS_Store b/subprojects/packagefiles/.DS_Store deleted file mode 100644 index 755eb1e48..000000000 Binary files a/subprojects/packagefiles/.DS_Store and /dev/null differ