From 8acda6655a9efe01c91869916db3275e4c909dca Mon Sep 17 00:00:00 2001 From: WofWca Date: Mon, 16 Mar 2026 22:25:29 +0400 Subject: [PATCH 1/5] fix: don't change scores after match end --- code/game/g_combat.c | 18 +++++++++++++++--- code/game/g_local.h | 2 +- code/game/g_team.c | 7 +++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/code/game/g_combat.c b/code/game/g_combat.c index 6ea2d866..9467eb78 100644 --- a/code/game/g_combat.c +++ b/code/game/g_combat.c @@ -37,10 +37,22 @@ void AddScore( gentity_t *ent, vec3_t origin, int score ) { if ( level.warmupTime ) { return; } - // show score plum - ScorePlum(ent, origin, score); + + // Ensure that the score doesn't change after match end + // (when `level.intermissionQueued`). + // This check is not present in the original Quake III Arena. + // This fixes a perhaps funny bug where you could `\kill` + // right after winning and it would decrease your score. // - ent->client->ps.persistant[PERS_SCORE] += score; + // Note that we do not early-return from this function. + // We'll do the same kind of check in `AddTeamScore()`. + if ( !level.intermissionQueued ) { + // show score plum + ScorePlum(ent, origin, score); + // + ent->client->ps.persistant[PERS_SCORE] += score; + } + if ( g_gametype.integer == GT_TEAM ) { AddTeamScore( origin, ent->client->ps.persistant[PERS_TEAM], score ); } diff --git a/code/game/g_local.h b/code/game/g_local.h index 38397e67..31052a6e 100644 --- a/code/game/g_local.h +++ b/code/game/g_local.h @@ -413,7 +413,7 @@ typedef struct { // wait INTERMISSION_DELAY_TIME before // actually going there so the last // frag can be watched. Disable future - // kills during this delay + // scores during this delay int intermissiontime; // time the intermission was started qboolean readyToExit; // at least one client wants to exit int exitTime; diff --git a/code/game/g_team.c b/code/game/g_team.c index 62362831..50a3fab8 100644 --- a/code/game/g_team.c +++ b/code/game/g_team.c @@ -123,6 +123,13 @@ void AddTeamScore( vec3_t origin, team_t team, int score ) { return; } + // See the same check in `AddScore()`. + // This has effect in flag-like game modes such as Harvest + // where it's possible for both teams to capture at the same time. + if ( level.intermissionQueued ) { + return; + } + eventParm = -1; otherTeam = OtherTeam( team ); From c4c6e2bd817b43e53af1e0f965fdacb326c46962 Mon Sep 17 00:00:00 2001 From: WofWca Date: Wed, 18 Mar 2026 21:40:44 +0400 Subject: [PATCH 2/5] feat: `g_canDamageAfterMatchEnd` CVAR, default `1` --- code/game/g_combat.c | 18 +++++++++++++++--- code/game/g_cvar.h | 2 ++ code/game/g_team.c | 2 ++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/code/game/g_combat.c b/code/game/g_combat.c index 9467eb78..c28c0e37 100644 --- a/code/game/g_combat.c +++ b/code/game/g_combat.c @@ -41,10 +41,12 @@ void AddScore( gentity_t *ent, vec3_t origin, int score ) { // Ensure that the score doesn't change after match end // (when `level.intermissionQueued`). // This check is not present in the original Quake III Arena. - // This fixes a perhaps funny bug where you could `\kill` + // It has been added mainly for `g_canDamageAfterMatchEnd`. + // This also fixes a perhaps funny bug where you could `\kill` // right after winning and it would decrease your score. // - // Note that we do not early-return from this function. + // Note that we do not early-return from this function, + // because we have to run `AddTeamScore()` in both cases. // We'll do the same kind of check in `AddTeamScore()`. if ( !level.intermissionQueued ) { // show score plum @@ -827,7 +829,17 @@ void G_Damage( gentity_t *targ, gentity_t *inflictor, gentity_t *attacker, // the intermission has allready been qualified for, so don't // allow any extra scoring - if ( level.intermissionQueued ) { + // + // When `g_canDamageAfterMatchEnd 1`, + // we'll do the `level.intermissionQueued` check + // in `AddScore` and `AddTeamScore` instead of doing it here. + // + // Note that when `g_canDamageAfterMatchEnd 1` + // it would make sense to check `level.intermissiontime` instead, + // but that would be further from the original game, + // because `level.intermissionQueued` gets reset to 0 + // when intermission starts. + if ( level.intermissionQueued && !g_canDamageAfterMatchEnd.integer ) { // With a special exception for gibbing bodies. // This was introduced in https://github.com/ec-/baseq3a/pull/50. diff --git a/code/game/g_cvar.h b/code/game/g_cvar.h index c9a8ad9b..df88742d 100644 --- a/code/game/g_cvar.h +++ b/code/game/g_cvar.h @@ -33,6 +33,8 @@ G_CVAR( g_synchronousClients, "g_synchronousClients", "0", CVAR_SYSTEMINFO, 0, q G_CVAR( g_friendlyFire, "g_friendlyFire", "0", CVAR_ARCHIVE, 0, qtrue, qfalse ) +G_CVAR( g_canDamageAfterMatchEnd, "g_canDamageAfterMatchEnd", "1", CVAR_ARCHIVE, 0, qtrue, qfalse ) + G_CVAR( g_autoJoin, "g_autoJoin", "1", CVAR_ARCHIVE, 0, qfalse, qfalse ) G_CVAR( g_teamForceBalance, "g_teamForceBalance", "0", CVAR_ARCHIVE, 0, qfalse, qfalse ) diff --git a/code/game/g_team.c b/code/game/g_team.c index 50a3fab8..53ee9e53 100644 --- a/code/game/g_team.c +++ b/code/game/g_team.c @@ -126,6 +126,8 @@ void AddTeamScore( vec3_t origin, team_t team, int score ) { // See the same check in `AddScore()`. // This has effect in flag-like game modes such as Harvest // where it's possible for both teams to capture at the same time. + // And also with `g_canDamageAfterMatchEnd 1` with `GT_TEAM` game type, + // and maybe more. if ( level.intermissionQueued ) { return; } From 4c51ee3b33a7b320406b00c95670e78b155a914c Mon Sep 17 00:00:00 2001 From: WofWca Date: Fri, 27 Mar 2026 16:46:41 +0400 Subject: [PATCH 3/5] fix: don't ruin "Perfect" if died after match end --- code/game/g_combat.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/code/game/g_combat.c b/code/game/g_combat.c index c28c0e37..82ca81d1 100644 --- a/code/game/g_combat.c +++ b/code/game/g_combat.c @@ -479,7 +479,11 @@ void player_die( gentity_t *self, gentity_t *inflictor, gentity_t *attacker, int self->enemy = attacker; - self->client->ps.persistant[PERS_KILLED]++; + // Check `level.intermissionQueued` so as to not ruin the "Perfect" award + // if the player got killed after the match has already ended. + if ( !level.intermissionQueued ) { + self->client->ps.persistant[PERS_KILLED]++; + } if (attacker && attacker->client) { attacker->client->lastkilled_client = self->s.number; From c2ffa34ddd27e0bdf1377f212fb3bf762af28d83 Mon Sep 17 00:00:00 2001 From: WofWca Date: Sat, 23 May 2026 21:25:28 +0400 Subject: [PATCH 4/5] feat: "Holy Shit!" when one "wins" after match end --- code/game/g_combat.c | 3 + code/game/g_local.h | 26 ++++++++ code/game/g_main.c | 149 +++++++++++++++++++++++++++++++++++++++++++ code/game/g_team.c | 3 + 4 files changed, 181 insertions(+) diff --git a/code/game/g_combat.c b/code/game/g_combat.c index 82ca81d1..d615ba2c 100644 --- a/code/game/g_combat.c +++ b/code/game/g_combat.c @@ -54,6 +54,9 @@ void AddScore( gentity_t *ent, vec3_t origin, int score ) { // ent->client->ps.persistant[PERS_SCORE] += score; } +#ifndef NO_HOLYSHIT_MOD + ent->client->pers.imaginaryScore += score; +#endif if ( g_gametype.integer == GT_TEAM ) { AddTeamScore( origin, ent->client->ps.persistant[PERS_TEAM], score ); diff --git a/code/game/g_local.h b/code/game/g_local.h index 31052a6e..053f9ece 100644 --- a/code/game/g_local.h +++ b/code/game/g_local.h @@ -239,6 +239,20 @@ typedef struct { int voted; int teamVoted; +#ifndef NO_HOLYSHIT_MOD + // These are only valid after match end, when `intermissionQueued`. + + // Like `ps.persistant[PERS_SCORE]`, but this can also change + // after match end, i.e. when `level.intermissionQueued`. + int imaginaryScore; + // Whether to play the "Holy shit!" sound if this player's `imaginaryScore` + // reaches `level.winnerScore`. + // This will be `qfalse` for the winner, disconnected and spectator players, + // in team games, and for the players + // for whom we've already played the sound. + qboolean needsHolyshitCheck; +#endif + qboolean inGame; } clientPersistant_t; @@ -368,6 +382,18 @@ typedef struct { int msec; // current frame duration int teamScores[TEAM_NUM_TEAMS]; +#ifndef NO_HOLYSHIT_MOD + // These are only valid after match end, when `intermissionQueued`. + + // The score of the winning player or team when the match ended + // (when `intermissionQueued` became non-zero). + int winnerScore; + // See `clientPersistant_t.imaginaryScore`. + int teamImaginaryScores[TEAM_NUM_TEAMS]; + // See `clientPersistant_t.needsHolyshitCheck`. + qboolean teamNeedsHolyshitCheck[TEAM_NUM_TEAMS]; +#endif + int lastTeamLocationTime; // last time of client team location update qboolean newSession; // don't use any old session data, because diff --git a/code/game/g_main.c b/code/game/g_main.c index 6b1d1f85..b27d17e4 100644 --- a/code/game/g_main.c +++ b/code/game/g_main.c @@ -1125,6 +1125,142 @@ void QDECL G_LogPrintf( const char *fmt, ... ) { } +#ifndef NO_HOLYSHIT_MOD +static void PlayGlobalHolyshitSound( void ) { + // Note that there is another way to play this sound, + // i.e. with `PLAYEREVENT_HOLYSHIT`, + // but that is per-player and not global. + gentity_t *te = G_TempEntity( vec3_origin, EV_GLOBAL_SOUND ); + te->s.eventParm = G_SoundIndex( "sound/feedback/voc_holyshit.wav" ); + te->r.svFlags |= SVF_BROADCAST; +} + +/* +============= +ClearHolyshit + +Sync imaginary scores to real scores +and clear all other holy-shit-related values. + +This should be called at match end, to ensure that the relevant fields +don't keep values from the previous match or something, +such as after `ExitLevel()`. +============= +*/ +static void ClearHolyshit( void ) { + int i; + + // assert( sizeof( level.teamImaginaryScores ) == sizeof( level.teamScores ) ); + memcpy( level.teamImaginaryScores, level.teamScores, sizeof( level.teamImaginaryScores ) ); + memset( level.teamNeedsHolyshitCheck, 0, sizeof( level.teamNeedsHolyshitCheck ) ); + // Setting to `MAX_QINT` basically ensures that nobody can reach this score. + // However we expect to never read `winnerScore` when it has this value. + // This is just to be defensive. + level.winnerScore = MAX_QINT; + + for ( i = 0 ; i < level.maxclients ; i++ ) { + gclient_t *cl = level.clients + i; + + // Not checking `connected` and `sessionTeam` as in `CheckExitRules`, + // just copy scores for all players as is. + + cl->pers.imaginaryScore = cl->ps.persistant[PERS_SCORE]; + cl->pers.needsHolyshitCheck = qfalse; + } +} +static void InitHolyshit( void ) { + ClearHolyshit(); + + if ( g_gametype.integer >= GT_TEAM ) { + level.winnerScore = level.teamScores[TEAM_RED] > level.teamScores[TEAM_BLUE] + ? level.teamScores[TEAM_RED] + : level.teamScores[TEAM_BLUE]; + level.teamNeedsHolyshitCheck[TEAM_RED] = + level.teamScores[TEAM_RED] < level.winnerScore; + level.teamNeedsHolyshitCheck[TEAM_BLUE] = + level.teamScores[TEAM_BLUE] < level.winnerScore; + } else { + int i; + const gclient_t *winner; + + if ( level.numPlayingClients < 1 ) { + // This shouldn't happen, but let's still gracefully handle. + // In this case nobody will have `needsHolyshitCheck == qtrue`. + return; + } + // Assuming `CalculateRanks()` has already been called. + winner = &level.clients[ level.sortedClients[0] ]; + + level.winnerScore = winner->ps.persistant[PERS_SCORE]; + + for ( i = 0; i < level.numPlayingClients; i++ ) { + gclient_t *cl = &level.clients[ level.sortedClients[i] ]; + if ( cl->ps.persistant[PERS_SCORE] < level.winnerScore ) { + cl->pers.needsHolyshitCheck = qtrue; + } + } + } +} +/* +============= +CheckHolyshit + +After match end but before intermission (when `level.intermissionQueued`), +check if someone else would have reached the winning score +if the match hadn't ended, and say the line if so. +This includes: +- Hitting the fraglimit after someone else has already hit it. + (`g_canDamageAfterMatchEnd 1` is required for this). +- Tying score after the timelimit has been hit. +- Tying score after sudden death. +- Bringing the cubes after the opponent has brought them. +============= +*/ +static void CheckHolyshit( void ) { + int i; + + if ( !level.intermissionQueued ) { + // This function should not be called when `!level.intermissionQueued`. + return; + } + + if ( level.teamNeedsHolyshitCheck[TEAM_RED] && + level.teamImaginaryScores[TEAM_RED] >= level.winnerScore ) + { + PlayGlobalHolyshitSound(); + G_BroadcastServerCommand( -1, + "print \"Red " S_COLOR_YELLOW "almost" S_COLOR_WHITE " didn't lose.\n\"" ); + level.teamNeedsHolyshitCheck[TEAM_RED] = qfalse; + } + if ( level.teamNeedsHolyshitCheck[TEAM_BLUE] && + level.teamImaginaryScores[TEAM_BLUE] >= level.winnerScore ) + { + PlayGlobalHolyshitSound(); + G_BroadcastServerCommand( -1, + "print \"Blue " S_COLOR_YELLOW "almost" S_COLOR_WHITE " didn't lose.\n\"" ); + level.teamNeedsHolyshitCheck[TEAM_BLUE] = qfalse; + } + + for ( i = 0 ; i < level.maxclients ; i++ ) { + gclient_t *cl = level.clients + i; + if ( cl->pers.connected != CON_CONNECTED ) { + continue; + } + + if ( cl->pers.needsHolyshitCheck && + cl->pers.imaginaryScore >= level.winnerScore ) + { + PlayGlobalHolyshitSound(); + G_BroadcastServerCommand( -1, va( + "print \"%s" S_COLOR_YELLOW " almost" S_COLOR_WHITE " didn't lose.\n\"", + cl->pers.netname ) ); + cl->pers.needsHolyshitCheck = qfalse; + } + } +} +#endif + + /* ================ LogExit @@ -1146,6 +1282,15 @@ void LogExit( const char *string ) { // that will get cut off when the queued intermission starts trap_SetConfigstring( CS_INTERMISSION, "1" ); +#ifndef NO_HOLYSHIT_MOD + InitHolyshit(); + // We don't expect a player (or team) to win and another player + // to almost win at the very same instant, + // so we don't need to also `CheckHolyshit()` here. + // Even if that were to happen, let's wait until another `CheckExitRules()`, + // hoping that this situation resolves by then. +#endif + // don't send more than 32 scores (FIXME?) numSorted = level.numConnectedClients; if ( numSorted > 32 ) { @@ -1331,6 +1476,10 @@ static void CheckExitRules( void ) { } if ( level.intermissionQueued ) { +#ifndef NO_HOLYSHIT_MOD + CheckHolyshit(); +#endif + #ifdef MISSIONPACK int time = (g_singlePlayer.integer) ? SP_INTERMISSION_DELAY_TIME : INTERMISSION_DELAY_TIME; if ( level.time - level.intermissionQueued >= time ) { diff --git a/code/game/g_team.c b/code/game/g_team.c index 53ee9e53..d5cc02cb 100644 --- a/code/game/g_team.c +++ b/code/game/g_team.c @@ -123,6 +123,9 @@ void AddTeamScore( vec3_t origin, team_t team, int score ) { return; } +#ifndef NO_HOLYSHIT_MOD + level.teamImaginaryScores[ team ] += score; +#endif // See the same check in `AddScore()`. // This has effect in flag-like game modes such as Harvest // where it's possible for both teams to capture at the same time. From 8fcba2feec928b141fe0ceaa469a66682359f193 Mon Sep 17 00:00:00 2001 From: WofWca Date: Sun, 24 May 2026 12:28:55 +0400 Subject: [PATCH 5/5] feat: "almost didn't lose" -> "was 0.123s away" --- code/game/g_main.c | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/code/game/g_main.c b/code/game/g_main.c index b27d17e4..c3b3e9ec 100644 --- a/code/game/g_main.c +++ b/code/game/g_main.c @@ -1134,6 +1134,18 @@ static void PlayGlobalHolyshitSound( void ) { te->s.eventParm = G_SoundIndex( "sound/feedback/voc_holyshit.wav" ); te->r.svFlags |= SVF_BROADCAST; } +static char* FormatHolyshitMsg( const char* name ) { + const int diffMs = level.time - level.intermissionQueued; + return va( "print \"%s" S_COLOR_WHITE " was %s away from not losing\n\"", + name, + diffMs == 0 + ? "one moment" + : diffMs < 500 + // Don't convert to float to avoid precision loss. + ? va( "%i.%03is", diffMs / 1000, diffMs % 1000 ) + : va( "%i.%01is", diffMs / 1000, (diffMs % 1000)/100 ) + ); +} /* ============= @@ -1228,16 +1240,14 @@ static void CheckHolyshit( void ) { level.teamImaginaryScores[TEAM_RED] >= level.winnerScore ) { PlayGlobalHolyshitSound(); - G_BroadcastServerCommand( -1, - "print \"Red " S_COLOR_YELLOW "almost" S_COLOR_WHITE " didn't lose.\n\"" ); + G_BroadcastServerCommand( -1, FormatHolyshitMsg( "Red" ) ); level.teamNeedsHolyshitCheck[TEAM_RED] = qfalse; } if ( level.teamNeedsHolyshitCheck[TEAM_BLUE] && level.teamImaginaryScores[TEAM_BLUE] >= level.winnerScore ) { PlayGlobalHolyshitSound(); - G_BroadcastServerCommand( -1, - "print \"Blue " S_COLOR_YELLOW "almost" S_COLOR_WHITE " didn't lose.\n\"" ); + G_BroadcastServerCommand( -1, FormatHolyshitMsg( "Blue" ) ); level.teamNeedsHolyshitCheck[TEAM_BLUE] = qfalse; } @@ -1251,9 +1261,7 @@ static void CheckHolyshit( void ) { cl->pers.imaginaryScore >= level.winnerScore ) { PlayGlobalHolyshitSound(); - G_BroadcastServerCommand( -1, va( - "print \"%s" S_COLOR_YELLOW " almost" S_COLOR_WHITE " didn't lose.\n\"", - cl->pers.netname ) ); + G_BroadcastServerCommand( -1, FormatHolyshitMsg( cl->pers.netname ) ); cl->pers.needsHolyshitCheck = qfalse; } }