1/* (c) Redix and Sushi */
2
3#include "race_demo.h"
4
5#include <base/process.h>
6#include <base/str.h>
7#include <base/time.h>
8
9#include <engine/shared/config.h>
10#include <engine/storage.h>
11
12#include <game/client/gameclient.h>
13#include <game/client/race.h>
14#include <game/localization.h>
15
16#include <cctype>
17#include <chrono>
18
19using namespace std::chrono_literals;
20
21const char *CRaceDemo::ms_pRaceDemoDir = "demos/auto/race";
22
23struct CDemoItem
24{
25 char m_aName[128];
26 int m_Time;
27};
28
29struct CDemoListParam
30{
31 const CRaceDemo *m_pThis;
32 std::vector<CDemoItem> *m_pvDemos;
33 const char *m_pMap;
34};
35
36CRaceDemo::CRaceDemo() :
37 m_RaceState(RACE_NONE), m_RaceStartTick(-1), m_RecordStopTick(-1), m_Time(0) {}
38
39void CRaceDemo::GetPath(char *pBuf, int Size, int Time) const
40{
41 const char *pMap = GameClient()->Map()->BaseName();
42
43 char aPlayerName[MAX_NAME_LENGTH];
44 str_copy(dst&: aPlayerName, src: Client()->PlayerName());
45 str_sanitize_filename(str: aPlayerName);
46
47 if(Time < 0)
48 str_format(buffer: pBuf, buffer_size: Size, format: "%s/%s_tmp_%d.demo", ms_pRaceDemoDir, pMap, process_id());
49 else if(g_Config.m_ClDemoName)
50 str_format(buffer: pBuf, buffer_size: Size, format: "%s/%s_%d.%03d_%s.demo", ms_pRaceDemoDir, pMap, Time / 1000, Time % 1000, aPlayerName);
51 else
52 str_format(buffer: pBuf, buffer_size: Size, format: "%s/%s_%d.%03d.demo", ms_pRaceDemoDir, pMap, Time / 1000, Time % 1000);
53}
54
55void CRaceDemo::OnStateChange(int NewState, int OldState)
56{
57 if(OldState == IClient::STATE_ONLINE)
58 StopRecord();
59}
60
61void CRaceDemo::OnNewSnapshot()
62{
63 if(!GameClient()->m_GameInfo.m_Race || !g_Config.m_ClAutoRaceRecord || Client()->State() != IClient::STATE_ONLINE)
64 return;
65
66 if(!GameClient()->m_Snap.m_pGameInfoObj || GameClient()->m_Snap.m_SpecInfo.m_Active || !GameClient()->m_Snap.m_pLocalCharacter || !GameClient()->m_Snap.m_pLocalPrevCharacter)
67 return;
68
69 static int s_LastRaceTick = -1;
70
71 bool RaceFlag = GameClient()->m_Snap.m_pGameInfoObj->m_GameStateFlags & GAMESTATEFLAG_RACETIME;
72 bool ServerControl = RaceFlag && g_Config.m_ClRaceRecordServerControl;
73 int RaceTick = -GameClient()->m_Snap.m_pGameInfoObj->m_WarmupTimer;
74
75 // start the demo
76 bool ForceStart = ServerControl && s_LastRaceTick != RaceTick && Client()->GameTick(Conn: g_Config.m_ClDummy) - RaceTick < Client()->GameTickSpeed();
77 bool AllowRestart = (m_AllowRestart || ForceStart) && m_RaceStartTick + 10 * Client()->GameTickSpeed() < Client()->GameTick(Conn: g_Config.m_ClDummy);
78 if(m_RaceState == RACE_IDLE || m_RaceState == RACE_PREPARE || (m_RaceState == RACE_STARTED && AllowRestart))
79 {
80 vec2 PrevPos = vec2(GameClient()->m_Snap.m_pLocalPrevCharacter->m_X, GameClient()->m_Snap.m_pLocalPrevCharacter->m_Y);
81 vec2 Pos = vec2(GameClient()->m_Snap.m_pLocalCharacter->m_X, GameClient()->m_Snap.m_pLocalCharacter->m_Y);
82
83 if(ForceStart || (!ServerControl && GameClient()->RaceHelper()->IsStart(Prev: PrevPos, Pos)))
84 {
85 if(m_RaceState == RACE_STARTED)
86 Client()->RaceRecord_Stop();
87 if(m_RaceState != RACE_PREPARE) // start recording again
88 {
89 GetPath(pBuf: m_aTmpFilename, Size: sizeof(m_aTmpFilename));
90 Client()->RaceRecord_Start(pFilename: m_aTmpFilename);
91 }
92 m_RaceStartTick = Client()->GameTick(Conn: g_Config.m_ClDummy);
93 m_RaceState = RACE_STARTED;
94 }
95 }
96
97 // start recording before the player passes the start line, so we can see some preparation steps
98 if(m_RaceState == RACE_NONE)
99 {
100 GetPath(pBuf: m_aTmpFilename, Size: sizeof(m_aTmpFilename));
101 Client()->RaceRecord_Start(pFilename: m_aTmpFilename);
102 m_RaceStartTick = Client()->GameTick(Conn: g_Config.m_ClDummy);
103 m_RaceState = RACE_PREPARE;
104 }
105
106 // stop recording if the player did not pass the start line after 20 seconds
107 if(m_RaceState == RACE_PREPARE && Client()->GameTick(Conn: g_Config.m_ClDummy) - m_RaceStartTick >= Client()->GameTickSpeed() * 20)
108 {
109 StopRecord();
110 m_RaceState = RACE_IDLE;
111 }
112
113 // stop the demo
114 if(m_RaceState == RACE_FINISHED && m_RecordStopTick <= Client()->GameTick(Conn: g_Config.m_ClDummy))
115 StopRecord(Time: m_Time);
116
117 s_LastRaceTick = RaceFlag ? RaceTick : -1;
118}
119
120void CRaceDemo::OnReset()
121{
122 StopRecord();
123}
124
125void CRaceDemo::OnShutdown()
126{
127 StopRecord();
128}
129
130void CRaceDemo::OnMessage(int MsgType, void *pRawMsg)
131{
132 // check for messages from server
133 if(MsgType == NETMSGTYPE_SV_KILLMSG)
134 {
135 CNetMsg_Sv_KillMsg *pMsg = (CNetMsg_Sv_KillMsg *)pRawMsg;
136 if(pMsg->m_Victim == GameClient()->m_Snap.m_LocalClientId && Client()->RaceRecord_IsRecording())
137 StopRecord(Time: m_Time);
138 }
139 else if(MsgType == NETMSGTYPE_SV_KILLMSGTEAM)
140 {
141 CNetMsg_Sv_KillMsgTeam *pMsg = (CNetMsg_Sv_KillMsgTeam *)pRawMsg;
142 for(int i = 0; i < MAX_CLIENTS; i++)
143 {
144 if(GameClient()->m_Teams.Team(ClientId: i) == pMsg->m_Team && i == GameClient()->m_Snap.m_LocalClientId && Client()->RaceRecord_IsRecording())
145 StopRecord(Time: m_Time);
146 }
147 }
148 else if(MsgType == NETMSGTYPE_SV_CHAT)
149 {
150 CNetMsg_Sv_Chat *pMsg = (CNetMsg_Sv_Chat *)pRawMsg;
151 if(pMsg->m_ClientId == -1 && m_RaceState == RACE_STARTED)
152 {
153 char aName[MAX_NAME_LENGTH];
154 int Time = CRaceHelper::TimeFromFinishMessage(pStr: pMsg->m_pMessage, pNameBuf: aName, NameBufSize: sizeof(aName));
155 if(Time > 0 && GameClient()->m_Snap.m_LocalClientId >= 0 && str_comp(a: aName, b: GameClient()->m_aClients[GameClient()->m_Snap.m_LocalClientId].m_aName) == 0)
156 {
157 m_RaceState = RACE_FINISHED;
158 m_RecordStopTick = Client()->GameTick(Conn: g_Config.m_ClDummy) + Client()->GameTickSpeed();
159 m_Time = Time;
160 }
161 }
162 }
163 else if(MsgType == NETMSGTYPE_SV_RACEFINISH)
164 {
165 CNetMsg_Sv_RaceFinish *pMsg = (CNetMsg_Sv_RaceFinish *)pRawMsg;
166 if(m_RaceState == RACE_STARTED && pMsg->m_ClientId == GameClient()->m_Snap.m_LocalClientId)
167 {
168 m_RaceState = RACE_FINISHED;
169 m_RecordStopTick = Client()->GameTick(Conn: g_Config.m_ClDummy) + Client()->GameTickSpeed();
170 m_Time = pMsg->m_Time;
171 }
172 }
173}
174
175void CRaceDemo::OnMapLoad()
176{
177 m_AllowRestart = false;
178}
179
180void CRaceDemo::StopRecord(int Time)
181{
182 if(Client()->RaceRecord_IsRecording())
183 Client()->RaceRecord_Stop();
184
185 if(m_aTmpFilename[0] != '\0')
186 {
187 if(Time > 0 && CheckDemo(Time))
188 {
189 // save file
190 char aNewFilename[512];
191 GetPath(pBuf: aNewFilename, Size: sizeof(aNewFilename), Time: m_Time);
192
193 Storage()->RenameFile(pOldFilename: m_aTmpFilename, pNewFilename: aNewFilename, Type: IStorage::TYPE_SAVE);
194 }
195 else // no new record
196 Storage()->RemoveFile(pFilename: m_aTmpFilename, Type: IStorage::TYPE_SAVE);
197
198 m_aTmpFilename[0] = '\0';
199 }
200
201 m_Time = 0;
202 m_RaceState = RACE_NONE;
203 m_RaceStartTick = -1;
204 m_RecordStopTick = -1;
205}
206
207struct SRaceDemoFetchUser
208{
209 CRaceDemo *m_pThis;
210 CDemoListParam *m_pParam;
211};
212
213int CRaceDemo::RaceDemolistFetchCallback(const CFsFileInfo *pInfo, int IsDir, int StorageType, void *pUser)
214{
215 auto *pRealUser = (SRaceDemoFetchUser *)pUser;
216 auto *pParam = pRealUser->m_pParam;
217 int MapLen = str_length(str: pParam->m_pMap);
218 if(IsDir || !str_endswith(str: pInfo->m_pName, suffix: ".demo") || !str_startswith(str: pInfo->m_pName, prefix: pParam->m_pMap) || pInfo->m_pName[MapLen] != '_')
219 return 0;
220
221 CDemoItem Item;
222 str_truncate(dst: Item.m_aName, dst_size: sizeof(Item.m_aName), src: pInfo->m_pName, truncation_len: str_length(str: pInfo->m_pName) - 5);
223
224 const char *pTime = Item.m_aName + MapLen + 1;
225 const char *pTEnd = pTime;
226 while(isdigit(*pTEnd) || *pTEnd == ' ' || *pTEnd == '.' || *pTEnd == ',')
227 pTEnd++;
228
229 if(g_Config.m_ClDemoName)
230 {
231 char aPlayerName[MAX_NAME_LENGTH];
232 str_copy(dst&: aPlayerName, src: pParam->m_pThis->Client()->PlayerName());
233 str_sanitize_filename(str: aPlayerName);
234
235 if(pTEnd[0] != '_' || str_comp(a: pTEnd + 1, b: aPlayerName) != 0)
236 return 0;
237 }
238 else if(pTEnd[0])
239 return 0;
240
241 Item.m_Time = CRaceHelper::TimeFromSecondsStr(pStr: pTime);
242 if(Item.m_Time > 0)
243 pParam->m_pvDemos->push_back(x: Item);
244
245 if(time_get_nanoseconds() - pRealUser->m_pThis->m_RaceDemosLoadStartTime > 500ms)
246 {
247 pRealUser->m_pThis->GameClient()->m_Menus.RenderLoading(pCaption: Localize(pStr: "Loading race demo files"), pContent: "", IncreaseCounter: 0);
248 }
249
250 return 0;
251}
252
253bool CRaceDemo::CheckDemo(int Time)
254{
255 std::vector<CDemoItem> vDemos;
256 CDemoListParam Param = {.m_pThis: this, .m_pvDemos: &vDemos, .m_pMap: GameClient()->Map()->BaseName()};
257 m_RaceDemosLoadStartTime = time_get_nanoseconds();
258 SRaceDemoFetchUser User;
259 User.m_pParam = &Param;
260 User.m_pThis = this;
261 Storage()->ListDirectoryInfo(Type: IStorage::TYPE_SAVE, pPath: ms_pRaceDemoDir, pfnCallback: RaceDemolistFetchCallback, pUser: &User);
262
263 // loop through demo files
264 for(auto &Demo : vDemos)
265 {
266 if(Time >= Demo.m_Time) // found a better demo
267 return false;
268
269 // delete old demo
270 char aFilename[IO_MAX_PATH_LENGTH];
271 str_format(buffer: aFilename, buffer_size: sizeof(aFilename), format: "%s/%s.demo", ms_pRaceDemoDir, Demo.m_aName);
272 Storage()->RemoveFile(pFilename: aFilename, Type: IStorage::TYPE_SAVE);
273 }
274
275 return true;
276}
277