1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
//! Ending TF2's rounds through its game rules (`CTFGameRules`): a team's win,
//! or a round without a winner, and sudden death, as the level's objectives
//! and timers end them.
//!
//! [`GameRules::set_winning_team`] and [`GameRules::set_stalemate`] call the
//! game rules' own methods, which a level's `game_round_win` calls when it is
//! sent `RoundWin`, but need no such entity. The round ends while the method
//! runs: the game fires `teamplay_round_win` or `teamplay_round_stalemate`,
//! and the callbacks of the game, of other plugins, and of this plugin run
//! before it returns, and must keep to the contract of [`Server::new`].
//!
//! [`Server::new`]: crate::Server::new
#[cfg(test)]
#[path = "../tests/tf2/round_end.rs"]
mod tests;
use crate::entities::EntityHandle;
use crate::interfaces::ServerTools;
use crate::tf2::game_rules::{GameRules, GameRulesError, RoundState};
use crate::tf2::objectives::WinReason;
use crate::tf2::scoreboard::ScoringTeam;
use sdk_raw::players::TEAM_UNASSIGNED;
use sdk_raw::tf2::game_rules as raw;
use std::ffi::c_int;
/// What follows a team's win, beyond who won and why: the options of
/// `SetWinningTeam`, whose [defaults](Self::default) are the game's.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WinOptions {
/// Whether the whole map resets after the bonus round (`bForceMapReset`),
/// as at the end of a full round, which counts the round as played,
/// rather than only the next mini-round of a level that plays several, as
/// multi-stage Attack/Defend levels do.
pub reset_map: bool,
/// Whether the teams switch sides as the map resets (`bSwitchTeams`).
pub switch_teams: bool,
/// Whether the winners score the round (the opposite of `bDontAddScore`),
/// which they only do if the map resets, or the time limit ran out, and if
/// the game scores rounds.
pub add_score: bool,
/// Whether the win ends the game (`bFinal`), which TF2 only uses to time
/// the winners' crit boost to the bonus round that ends a game, which
/// competitive matches shorten.
pub final_round: bool,
}
impl Default for WinOptions {
/// A full round's end, which resets the map and scores the round, without
/// switching teams or ending the game.
fn default() -> Self {
Self {
reset_map: true,
switch_teams: false,
add_score: true,
final_round: false,
}
}
}
/// What follows sudden death, or a round's end without a winner while
/// `mp_stalemate_enable` is off: the options of `SetStalemate`, whose
/// [defaults](Self::default) are the game's.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct StalemateOptions {
/// Whether the whole map resets once the round ends (`bForceMapReset`), as
/// [`WinOptions::reset_map`] does.
pub reset_map: bool,
/// Whether the teams switch sides as the map resets (`bSwitchTeams`), which
/// TF2 only passes on to a round's end without a winner, while
/// `mp_stalemate_enable` is off.
pub switch_teams: bool,
}
impl Default for StalemateOptions {
/// A stalemate that resets the map, without switching teams.
fn default() -> Self {
Self {
reset_map: true,
switch_teams: false,
}
}
}
/// Why a round went to sudden death, which `teamplay_round_stalemate` tells
/// its listeners (`STALEMATE_`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(i32)]
pub enum StalemateReason {
/// Players joined mid-round.
#[doc(alias("STALEMATE_JOIN_MID"))]
JoinedMidRound = raw::STALEMATE_JOIN_MID,
/// The round's timer ran out.
#[doc(alias("STALEMATE_TIMER"))]
Timer = raw::STALEMATE_TIMER,
/// The level's time limit ran out.
#[doc(alias("STALEMATE_SERVER_TIMELIMIT"))]
TimeLimit = raw::STALEMATE_SERVER_TIMELIMIT,
}
impl StalemateReason {
/// Every reason, in the game's order.
pub const ALL: [Self; 3] = [Self::JoinedMidRound, Self::Timer, Self::TimeLimit];
/// The reason the game numbers `raw`, or `None` for any other value.
pub const fn from_raw(raw: c_int) -> Option<Self> {
match raw {
raw::STALEMATE_JOIN_MID => Some(Self::JoinedMidRound),
raw::STALEMATE_TIMER => Some(Self::Timer),
raw::STALEMATE_SERVER_TIMELIMIT => Some(Self::TimeLimit),
_ => None,
}
}
/// The value the game numbers the reason with.
pub const fn to_raw(self) -> c_int {
self as c_int
}
}
/// Why a round could not be ended.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RoundEndError {
/// The game rules could not be read.
#[error(transparent)]
GameRules(#[from] GameRulesError),
/// A team already won the round. TF2 would still crit boost the team and
/// count King of the Hill's and Payload Race's progress again, before the
/// game rules ignore the win.
#[error("a team already won the round")]
AlreadyWon,
/// The level plays King of the Hill, but the team's King of the Hill timer
/// does not exist, which the game would read without checking. Its
/// `tf_logic_koth` creates the timers as each round spawns.
#[error("the {0:?} team's King of the Hill timer does not exist")]
NoKothTimer(ScoringTeam),
}
/// Ending the round, which [the module](self) describes.
impl GameRules<'_> {
/// Checks that ending the round is safe, and has TF2 do no more than the
/// game rules accept.
pub(crate) fn check_round_end(self, tools: ServerTools<'_>) -> Result<(), RoundEndError> {
if self.round_state()? == RoundState::TeamWin {
return Err(RoundEndError::AlreadyWon);
}
if self.is_king_of_the_hill()? {
for team in [ScoringTeam::Red, ScoringTeam::Blue] {
let timer = self.koth_timer(team)?;
if timer
.and_then(|timer| tools.entity_by_handle(timer))
.is_none()
{
return Err(RoundEndError::NoKothTimer(team));
}
}
}
Ok(())
}
/// `team`'s King of the Hill timer, as the game rules hold it
/// (`m_hRedKothTimer`), which counts down while the team holds the point,
/// or `None` if it has none, as on levels that do not play King of the
/// Hill, and before a round spawns the timers.
///
/// [`KothLogic::timer`](crate::tf2::objectives::KothLogic::timer) finds
/// the same timers by name.
#[doc(alias("m_hRedKothTimer", "m_hBlueKothTimer", "GetRedKothRoundTimer"))]
pub fn koth_timer(self, team: ScoringTeam) -> Result<Option<EntityHandle>, GameRulesError> {
self.read_handle(match team {
ScoringTeam::Red => c"m_hRedKothTimer",
ScoringTeam::Blue => c"m_hBlueKothTimer",
})
}
/// Starts sudden death for `reason` (`SetStalemate`), which lasts until a
/// team wins it, or its timer (`mp_stalemate_timelimit`) ends the round
/// without a winner. Every building and projectile is removed, and outside
/// Arena, players respawn, and health packs are disabled until it ends.
/// While `mp_stalemate_enable` is off, the round ends without a winner
/// straight away, as [`Self::set_winning_team`] ends it with `None`.
///
/// The game ignores the stalemate during sudden death, and in a
/// tournament's pre-match. Fails, without calling the game, as
/// [`Self::set_winning_team`] does.
#[doc(alias("SetStalemate"))]
pub fn set_stalemate(
self,
tools: ServerTools<'_>,
reason: StalemateReason,
options: StalemateOptions,
) -> Result<(), RoundEndError> {
self.check_round_end(tools)?;
// SAFETY: The game rules are TF2's live `CTFGameRules`, as
// `GameRules::get` checks the game, and they are only used on the main
// thread. A round's end without a winner reads King of the Hill's
// timers, which exist, as checked. What the method runs frees entities
// only through deferred deletion (`Server::new`'s contract).
unsafe {
raw::set_stalemate(
self.as_non_null(),
reason.to_raw(),
options.reset_map,
options.switch_teams,
);
}
Ok(())
}
/// Ends the round with `winner`'s win, or without a winner for `None`,
/// for `reason` (`SetWinningTeam`), and starts the bonus round, in which
/// the winners' living players are crit boosted and the losers humiliated.
///
/// The game ignores the win in commentary mode, after TF2 has crit boosted
/// the winners. Fails with [`RoundEndError::AlreadyWon`], without calling
/// the game, while a team has already won the round, and with
/// [`RoundEndError::NoKothTimer`] on a King of the Hill level whose round
/// has not spawned the teams' timers, which the game would read.
#[doc(alias("SetWinningTeam"))]
pub fn set_winning_team(
self,
tools: ServerTools<'_>,
winner: Option<ScoringTeam>,
reason: WinReason,
options: WinOptions,
) -> Result<(), RoundEndError> {
self.check_round_end(tools)?;
let team = winner.map_or(TEAM_UNASSIGNED, ScoringTeam::to_raw);
// SAFETY: As for `set_stalemate`. The team is a playing team or
// `TEAM_UNASSIGNED`.
unsafe {
raw::set_winning_team(
self.as_non_null(),
team,
reason.to_raw(),
options.reset_map,
options.switch_teams,
!options.add_score,
options.final_round,
);
}
Ok(())
}
}