Skip to content

Commit 3b2428c

Browse files
Replace broken pausable-based pause with round-transition timeout
Xash3D has no engine pause mechanism - pausable/sv_paused do nothing. This implements MatchBot-style round-transition pauses: - !pause/.timeout/.tech defer to next round start instead of attempting engine freeze - On round restart, freezetime is extended to the pause duration via mp_freezetime cvar + CSGameRules manipulation - OnStartFrame and OnRoundFreezeEnd re-freeze as safety net - Unpause restores mp_freezetime, mp_buytime, and ends freezetime - Tech pauses require both teams to .unpause (consensus voting preserved) - Timed pauses auto-unpause and broadcast countdown every 15s - Also fixes .teamname by trimming DispatchCommand input (handles leading space in pfnCmd_Args from some Xash3D builds) and using robust substring extraction Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 0a8b9cb commit 3b2428c

2 files changed

Lines changed: 177 additions & 28 deletions

File tree

src/plugin.cpp

Lines changed: 167 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include "plugin.h"
2+
#include "regamedll.h"
23

34
#ifndef __linux__
45
#define __linux__
@@ -21,6 +22,16 @@ Plugin &GetPlugin()
2122
return plugin;
2223
}
2324

25+
// Access CS game rules through ReGameDLL API instead of the g_pGameRules extern
26+
// (which lives in the game DLL and is not linkable from our plugin).
27+
static CHalfLifeMultiplay *GetGameRules()
28+
{
29+
if (g_ReGameApi) {
30+
return static_cast<CHalfLifeMultiplay *>(g_ReGameApi->GetGameRules());
31+
}
32+
return nullptr;
33+
}
34+
2435
void Plugin::OnMetaAttach()
2536
{
2637
std::srand(static_cast<unsigned>(std::time(nullptr)));
@@ -95,6 +106,12 @@ void Plugin::ForceStartFromServer()
95106

96107
void Plugin::OnServerActivate()
97108
{
109+
mpFreezeTimeCvar_ = g_engfuncs.pfnCVarGetPointer("mp_freezetime");
110+
mpBuyTimeCvar_ = g_engfuncs.pfnCVarGetPointer("mp_buytime");
111+
roundTimeMsgId_ = gpMetaUtilFuncs->pfnGetUserMsgID(&Plugin_info, "RoundTime", NULL);
112+
if (roundTimeMsgId_ <= 0) {
113+
Log("OnServerActivate: RoundTime message not found\n");
114+
}
98115
LoadAdmins();
99116
ResetMatch(true);
100117
SetState(CvarInt(cvars_.enabled) ? MatchState::Warmup : MatchState::Disabled);
@@ -130,6 +147,12 @@ void Plugin::OnStartFrame()
130147
callback();
131148
}
132149
}
150+
151+
// Safety: keep the round frozen while paused
152+
if (paused_ && GetGameRules() && !GetGameRules()->m_bFreezePeriod) {
153+
GetGameRules()->m_bFreezePeriod = TRUE;
154+
GetGameRules()->m_fRoundStartTime = gpGlobals->time;
155+
}
133156
}
134157

135158
bool Plugin::OnClientConnect(edict_t *entity, const char *name)
@@ -359,7 +382,7 @@ void Plugin::LoadAdmins()
359382
void Plugin::ResetMatch(bool keepWarmup)
360383
{
361384
ClearTasks();
362-
ServerCommand("pausable 0\n");
385+
CancelTask("pause_countdown");
363386
RestoreKnifeRoundWeapons();
364387
for (auto &player : players_) {
365388
player.ready = false;
@@ -378,6 +401,8 @@ void Plugin::ResetMatch(bool keepWarmup)
378401
lastObservedCTScore_ = 0;
379402
paused_ = false;
380403
techPaused_ = false;
404+
pauseRequested_ = false;
405+
pauseDuration_ = 0;
381406
halftimeScoresSaved_ = false;
382407
techUnpauseVotes_.clear();
383408
restarting_ = false;
@@ -748,65 +773,146 @@ void Plugin::RestartMatch()
748773
StartReady();
749774
}
750775

751-
void Plugin::PauseMatch()
776+
void Plugin::RequestPause(const char *caller, int duration, bool isTech)
752777
{
753-
if (paused_) {
754-
Broadcast("[XMP] Match is already paused.\n");
778+
if (pauseRequested_) {
779+
Broadcast("[XMP] A pause is already queued for the next round.\n");
755780
return;
756781
}
757-
paused_ = true;
758-
techPaused_ = false;
782+
if (!IsLiveState(state_)) {
783+
Broadcast("[XMP] Cannot pause outside of a live match.\n");
784+
return;
785+
}
786+
pauseRequested_ = true;
787+
pauseDuration_ = duration;
788+
techPaused_ = isTech;
759789
techUnpauseVotes_.clear();
760-
Broadcast("[XMP] Match paused for %.0f seconds.\n", CvarFloat(cvars_.pauseTime));
761-
ServerCommand("pausable 1\n");
762-
Schedule("pause", CvarFloat(cvars_.pauseTime), false, [this]() { UnpauseMatch(); });
790+
if (isTech) {
791+
Broadcast("[XMP] %s called a technical timeout. Match will pause on next round start — both teams must .unpause to continue.\n", caller);
792+
} else {
793+
Broadcast("[XMP] %s paused the match. Pausing on next round start for %d seconds.\n", caller, duration);
794+
}
795+
}
796+
797+
void Plugin::PauseMatch()
798+
{
799+
if (paused_ || pauseRequested_) {
800+
Broadcast("[XMP] Match is already pausing or paused.\n");
801+
return;
802+
}
803+
RequestPause("Admin", static_cast<int>(CvarFloat(cvars_.pauseTime)), false);
763804
}
764805

765806
void Plugin::TimeoutMatch()
766807
{
767-
if (paused_) {
768-
Broadcast("[XMP] Match is already paused.\n");
808+
if (paused_ || pauseRequested_) {
809+
Broadcast("[XMP] Match is already pausing or paused.\n");
769810
return;
770811
}
771812
if (!IsLiveState(state_)) {
772813
Broadcast("[XMP] Timeout can only be called during a live match.\n");
773814
return;
774815
}
775-
paused_ = true;
776-
techPaused_ = false;
777-
techUnpauseVotes_.clear();
778-
Broadcast("[XMP] Timeout called — match paused for %.0f seconds.\n", CvarFloat(cvars_.timeoutTime));
779-
ServerCommand("pausable 1\n");
780-
Schedule("pause", CvarFloat(cvars_.timeoutTime), false, [this]() { UnpauseMatch(); });
816+
RequestPause("Timeout", static_cast<int>(CvarFloat(cvars_.timeoutTime)), false);
781817
}
782818

783819
void Plugin::TechTimeout()
784820
{
785-
if (paused_) {
786-
Broadcast("[XMP] Match is already paused.\n");
821+
if (paused_ || pauseRequested_) {
822+
Broadcast("[XMP] Match is already pausing or paused.\n");
787823
return;
788824
}
789825
if (!IsLiveState(state_)) {
790826
Broadcast("[XMP] Technical timeout can only be called during a live match.\n");
791827
return;
792828
}
829+
RequestPause("Tech", static_cast<int>(CvarFloat(cvars_.timeoutTime)), true);
830+
}
831+
832+
void Plugin::ApplyPause()
833+
{
834+
if (!GetGameRules()) {
835+
Log("ApplyPause: CSGameRules is null, cannot pause\n");
836+
return;
837+
}
793838
paused_ = true;
794-
techPaused_ = true;
795-
techUnpauseVotes_.clear();
796-
Broadcast("[XMP] Technical timeout called. Match paused until both teams .unpause.\n");
797-
ServerCommand("pausable 1\n");
839+
pauseRequested_ = false;
840+
841+
if (mpFreezeTimeCvar_) savedFreezeTime_ = mpFreezeTimeCvar_->value;
842+
if (mpBuyTimeCvar_) savedBuyTime_ = mpBuyTimeCvar_->value;
843+
844+
// Extend mp_freezetime so the engine keeps the round frozen for the pause duration
845+
if (mpFreezeTimeCvar_) {
846+
g_engfuncs.pfnCvar_DirectSet(mpFreezeTimeCvar_, std::to_string(pauseDuration_).c_str());
847+
}
848+
if (mpBuyTimeCvar_) {
849+
g_engfuncs.pfnCvar_DirectSet(mpBuyTimeCvar_, std::to_string(pauseDuration_).c_str());
850+
}
851+
852+
GetGameRules()->m_bFreezePeriod = TRUE;
853+
GetGameRules()->m_fRoundStartTime = gpGlobals->time;
854+
GetGameRules()->m_iRoundTimeSecs = pauseDuration_ + 1;
855+
GetGameRules()->m_iIntroRoundTime = pauseDuration_ + 1;
856+
857+
// Send RoundTime message so the client HUD reflects the extended timer
858+
if (roundTimeMsgId_ > 0) {
859+
g_engfuncs.pfnMessageBegin(MSG_ALL, roundTimeMsgId_, NULL, NULL);
860+
g_engfuncs.pfnWriteShort(static_cast<int>(pauseDuration_ + 1));
861+
g_engfuncs.pfnMessageEnd();
862+
}
863+
864+
// Periodic countdown updates every 15 seconds
865+
if (techPaused_) {
866+
Broadcast("[XMP] Technical timeout active. Both teams type .unpause to continue.\n");
867+
} else {
868+
Broadcast("[XMP] Match paused for %d seconds.\n", pauseDuration_);
869+
Schedule("pause", static_cast<float>(pauseDuration_), false, [this]() { UnpauseMatch(); });
870+
}
871+
872+
Schedule("pause_countdown", 15.0f, true, [this]() {
873+
if (!paused_ || !GetGameRules()) return;
874+
const float elapsed = gpGlobals->time - GetGameRules()->m_fRoundStartTime;
875+
const int remaining = pauseDuration_ - static_cast<int>(elapsed);
876+
if (remaining > 0 && !techPaused_) {
877+
Broadcast("[XMP] Match unpausing in ~%d seconds.\n", remaining);
878+
}
879+
});
798880
}
799881

800882
void Plugin::UnpauseMatch()
801883
{
802884
if (!paused_) {
803885
return;
804886
}
887+
888+
CancelTask("pause");
889+
CancelTask("pause_countdown");
890+
891+
// Restore original freezetime
892+
if (mpFreezeTimeCvar_ && savedFreezeTime_ > 0.0f) {
893+
char buf[32];
894+
snprintf(buf, sizeof(buf), "%.0f", savedFreezeTime_);
895+
g_engfuncs.pfnCvar_DirectSet(mpFreezeTimeCvar_, buf);
896+
}
897+
898+
// Restore original buytime
899+
if (mpBuyTimeCvar_ && savedBuyTime_ > 0.0f) {
900+
char buf[32];
901+
snprintf(buf, sizeof(buf), "%.0f", savedBuyTime_);
902+
g_engfuncs.pfnCvar_DirectSet(mpBuyTimeCvar_, buf);
903+
}
904+
905+
// End freezetime
906+
if (GetGameRules()) {
907+
GetGameRules()->m_bFreezePeriod = FALSE;
908+
}
909+
805910
paused_ = false;
806911
techPaused_ = false;
912+
pauseRequested_ = false;
913+
pauseDuration_ = 0;
807914
techUnpauseVotes_.clear();
808-
CancelTask("pause");
809-
ServerCommand("pausable 0\n");
915+
810916
Broadcast("[XMP] Match unpaused.\n");
811917
}
812918

@@ -1520,8 +1626,16 @@ void Plugin::FinishMatch()
15201626
}
15211627
}
15221628

1523-
bool Plugin::DispatchCommand(edict_t *entity, const std::string &raw)
1629+
bool Plugin::DispatchCommand(edict_t *entity, std::string raw)
15241630
{
1631+
// Trim leading whitespace — some engine builds include it in pfnCmd_Args()
1632+
const auto first = raw.find_first_not_of(" \t\r\n\"");
1633+
if (first == std::string::npos) {
1634+
return false;
1635+
}
1636+
if (first > 0) {
1637+
raw = raw.substr(first);
1638+
}
15251639
if (raw.empty()) {
15261640
return false;
15271641
}
@@ -1563,7 +1677,20 @@ bool Plugin::DispatchPlayerCommand(edict_t *entity, const std::string &command)
15631677
Say(entity, "[XMP] Can only set team name before the match starts.\n");
15641678
return true;
15651679
}
1566-
const std::string name = (normalized.size() > 9) ? normalized.substr(9) : "";
1680+
// Extract name after "teamname " prefix, skipping any extra whitespace
1681+
const std::string prefix = "teamname ";
1682+
std::string name;
1683+
if (normalized.size() > prefix.size()) {
1684+
name = normalized.substr(prefix.size());
1685+
// Strip leading/trailing whitespace from the name itself
1686+
const auto first = name.find_first_not_of(" \t\r\n\"");
1687+
const auto last = name.find_last_not_of(" \t\r\n\"");
1688+
if (first != std::string::npos) {
1689+
name = name.substr(first, last - first + 1);
1690+
} else {
1691+
name.clear();
1692+
}
1693+
}
15671694
if (name.empty() || name.length() > 32) {
15681695
Say(entity, "[XMP] Usage: .teamname <name> (max 32 characters).\n");
15691696
return true;
@@ -1940,10 +2067,23 @@ void Plugin::OnRoundEnd(int winStatus)
19402067
void Plugin::OnRoundRestart()
19412068
{
19422069
Log("OnRoundRestart");
2070+
if (pauseRequested_) {
2071+
ApplyPause();
2072+
}
2073+
if (paused_) {
2074+
// Re-apply pause state after round restart resets it
2075+
if (GetGameRules()) {
2076+
GetGameRules()->m_bFreezePeriod = TRUE;
2077+
}
2078+
}
19432079
}
19442080

19452081
void Plugin::OnRoundFreezeEnd()
19462082
{
2083+
// If still paused, re-freeze to maintain the pause
2084+
if (paused_ && GetGameRules()) {
2085+
GetGameRules()->m_bFreezePeriod = TRUE;
2086+
}
19472087
}
19482088

19492089
bool Plugin::OnPlayerSpawnEquip(CBasePlayer *player, bool addDefault, bool equipGame)

src/plugin.h

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ class Plugin {
163163
void UnpauseMatch();
164164
void TimeoutMatch();
165165
void TechTimeout();
166+
void RequestPause(const char *caller, int duration, bool isTech);
167+
void ApplyPause();
166168
void SwapTeams();
167169
void AssignRandomModelForTeam(edict_t *entity, Team team);
168170
int RandomClassSlotForTeam(Team team) const;
@@ -210,7 +212,7 @@ class Plugin {
210212
void EnforceKnifeRoundPlayerNative(edict_t *entity);
211213
bool IsKnifeRoundState(MatchState state) const;
212214

213-
bool DispatchCommand(edict_t *entity, const std::string &raw);
215+
bool DispatchCommand(edict_t *entity, std::string raw);
214216
bool DispatchPlayerCommand(edict_t *entity, const std::string &command);
215217
bool DispatchAdminCommand(edict_t *entity, const std::string &command);
216218
bool IsAdmin(edict_t *entity) const;
@@ -271,6 +273,13 @@ class Plugin {
271273
bool recording_ = false;
272274
bool techPaused_ = false;
273275
bool halftimeScoresSaved_ = false;
276+
bool pauseRequested_ = false;
277+
int pauseDuration_ = 0;
278+
float savedFreezeTime_ = 0.0f;
279+
float savedBuyTime_ = 0.0f;
280+
cvar_t *mpFreezeTimeCvar_ = nullptr;
281+
cvar_t *mpBuyTimeCvar_ = nullptr;
282+
int roundTimeMsgId_ = 0;
274283
std::set<int> techUnpauseVotes_{};
275284
std::string teamAName_;
276285
std::string teamBName_;

0 commit comments

Comments
 (0)