diff --git a/CMakeLists.txt b/CMakeLists.txt
index 395451b81..2def8e284 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -731,6 +731,8 @@ set_src(ENGINE_SHARED GLOB src/engine/shared
config_variables.h
console.cpp
console.h
+ csv.cpp
+ csv.h
datafile.cpp
datafile.h
demo.cpp
@@ -1336,6 +1338,7 @@ if(GTEST_FOUND OR DOWNLOAD_GTEST)
set_src(TESTS GLOB src/test
aio.cpp
color.cpp
+ csv.cpp
datafile.cpp
fs.cpp
git_revision.cpp
diff --git a/src/engine/shared/csv.cpp b/src/engine/shared/csv.cpp
new file mode 100644
index 000000000..0fc90a879
--- /dev/null
+++ b/src/engine/shared/csv.cpp
@@ -0,0 +1,40 @@
+#include "csv.h"
+
+void CsvWrite(IOHANDLE File, int NumColumns, const char *const *ppColumns)
+{
+ for(int i = 0; i < NumColumns; i++)
+ {
+ if(i != 0)
+ {
+ io_write(File, ",", 1);
+ }
+ const char *pColumn = ppColumns[i];
+ int ColumnLength = str_length(pColumn);
+ if(!str_find(pColumn, "\"") && !str_find(pColumn, ","))
+ {
+ io_write(File, pColumn, ColumnLength);
+ continue;
+ }
+
+ int Start = 0;
+ io_write(File, "\"", 1);
+ for(int j = 0; j < ColumnLength; j++)
+ {
+ if(pColumn[j] == '"')
+ {
+ if(Start != j)
+ {
+ io_write(File, pColumn + Start, j - Start);
+ }
+ Start = j + 1;
+ io_write(File, "\"\"", 2);
+ }
+ }
+ if(Start != ColumnLength)
+ {
+ io_write(File, pColumn + Start, ColumnLength - Start);
+ }
+ io_write(File, "\"", 1);
+ }
+ io_write_newline(File);
+}
diff --git a/src/engine/shared/csv.h b/src/engine/shared/csv.h
new file mode 100644
index 000000000..aa5273382
--- /dev/null
+++ b/src/engine/shared/csv.h
@@ -0,0 +1,8 @@
+#ifndef ENGINE_SHARED_CSV_H
+#define ENGINE_SHARED_CSV_H
+
+#include
+
+void CsvWrite(IOHANDLE File, int NumColumns, const char *const *pColumns);
+
+#endif // ENGINE_SHARED_CSV_H
diff --git a/src/game/client/components/chat.cpp b/src/game/client/components/chat.cpp
index 2e7eef3bd..bc0c98792 100644
--- a/src/game/client/components/chat.cpp
+++ b/src/game/client/components/chat.cpp
@@ -9,6 +9,7 @@
#include
#include
#include
+#include
#include
#include
@@ -543,6 +544,63 @@ bool CChat::LineShouldHighlight(const char *pLine, const char *pName)
return false;
}
+#define SAVES_FILE "ddnet-saves.txt"
+const char *SAVES_HEADER[] = {
+ "Time",
+ "Player",
+ "Map",
+ "Code",
+};
+
+void CChat::StoreSave(const char *pText)
+{
+ const char *pStart = str_find(pText, "Team successfully saved by ");
+ const char *pMid = str_find(pText, ". Use '/load ");
+ const char *pEnd = str_find(pText, "' to continue");
+
+ if(!pStart || !pMid || !pEnd || pMid < pStart || pEnd < pMid)
+ return;
+
+ char aName[16];
+ str_copy(aName, pStart + 27, minimum(static_cast(pMid - pStart - 26), sizeof(aName)));
+
+ char aSaveCode[64];
+ str_copy(aSaveCode, pMid + 13, minimum(static_cast(pEnd - pMid - 12), sizeof(aSaveCode)));
+
+ char aTimestamp[20];
+ str_timestamp_format(aTimestamp, sizeof(aTimestamp), FORMAT_SPACE);
+
+ // TODO: Find a simple way to get the names of team members. This doesn't
+ // work since team is killed first, then save message gets sent:
+ /*
+ for(int i = 0; i < MAX_CLIENTS; i++)
+ {
+ const CNetObj_PlayerInfo *pInfo = GameClient()->m_Snap.m_paInfoByDDTeam[i];
+ if(!pInfo)
+ continue;
+ pInfo->m_Team // All 0
+ }
+ */
+
+ IOHANDLE File = Storage()->OpenFile(SAVES_FILE, IOFLAG_APPEND, IStorage::TYPE_SAVE);
+ if(!File)
+ return;
+
+ const char *apColumns[4] = {
+ aTimestamp,
+ aName,
+ Client()->GetCurrentMap(),
+ aSaveCode,
+ };
+
+ if(io_tell(File) == 0)
+ {
+ CsvWrite(File, 4, SAVES_HEADER);
+ }
+ CsvWrite(File, 4, apColumns);
+ io_close(File);
+}
+
void CChat::AddLine(int ClientID, int Team, const char *pLine)
{
if(*pLine == 0 ||
@@ -640,6 +698,9 @@ void CChat::AddLine(int ClientID, int Team, const char *pLine)
{
str_copy(m_aLines[m_CurrentLine].m_aName, "*** ", sizeof(m_aLines[m_CurrentLine].m_aName));
str_format(m_aLines[m_CurrentLine].m_aText, sizeof(m_aLines[m_CurrentLine].m_aText), "%s", pLine);
+
+ if(Client()->State() != IClient::STATE_DEMOPLAYBACK)
+ StoreSave(m_aLines[m_CurrentLine].m_aText);
}
else
{
diff --git a/src/game/client/components/chat.h b/src/game/client/components/chat.h
index daf004a00..13f397459 100644
--- a/src/game/client/components/chat.h
+++ b/src/game/client/components/chat.h
@@ -91,6 +91,7 @@ class CChat : public CComponent
static void ConEcho(IConsole::IResult *pResult, void *pUserData);
bool LineShouldHighlight(const char *pLine, const char *pName);
+ void StoreSave(const char *pText);
public:
CChat();
diff --git a/src/test/csv.cpp b/src/test/csv.cpp
new file mode 100644
index 000000000..bd2e6cf1f
--- /dev/null
+++ b/src/test/csv.cpp
@@ -0,0 +1,56 @@
+#include "test.h"
+#include
+
+#include
+
+static void Expect(int NumColumns, const char *const *ppColumns, const char *pExpected)
+{
+ CTestInfo Info;
+
+ IOHANDLE File = io_open(Info.m_aFilename, IOFLAG_WRITE);
+ ASSERT_TRUE(File);
+ CsvWrite(File, NumColumns, ppColumns);
+ io_close(File);
+
+ char aBuf[1024];
+ File = io_open(Info.m_aFilename, IOFLAG_READ);
+ ASSERT_TRUE(File);
+ int Read = io_read(File, aBuf, sizeof(aBuf));
+ io_close(File);
+ fs_remove(Info.m_aFilename);
+
+ ASSERT_TRUE(Read >= 1);
+ Read -= 1;
+ ASSERT_EQ(aBuf[Read], '\n');
+ aBuf[Read] = 0;
+
+ #if defined(CONF_FAMILY_WINDOWS)
+ ASSERT_TRUE(Read >= 1);
+ Read -= 1;
+ ASSERT_EQ(aBuf[Read], '\r');
+ aBuf[Read] = 0;
+ #endif
+
+ for(int i = 0; i < Read; i++)
+ {
+ EXPECT_NE(aBuf[i], 0);
+ }
+ EXPECT_STREQ(aBuf, pExpected);
+}
+
+TEST(Csv, Simple)
+{
+ const char *apCols1[] = {"a", "b"}; Expect(2, apCols1, "a,b");
+ const char *apCols2[] = {"こんにちは"}; Expect(1, apCols2, "こんにちは");
+ const char *apCols3[] = {"я", "", "й"}; Expect(3, apCols3, "я,,й");
+ const char *apCols4[] = {""}; Expect(1, apCols4, "");
+ const char *apCols5[] = {0}; Expect(0, apCols5, "");
+}
+
+TEST(Csv, LetTheQuotingBegin)
+{
+ const char *apCols1[] = {"\""}; Expect(1, apCols1, "\"\"\"\"");
+ const char *apCols2[] = {","}; Expect(1, apCols2, "\",\"");
+ const char *apCols3[] = {",,", ",\"\"\""}; Expect(2, apCols3, "\",,\",\",\"\"\"\"\"\"\"");
+ const char *apCols4[] = {"\",", " "}; Expect(2, apCols4, "\"\"\",\", ");
+}