From b2fba9d35b29641c5126ed04b4a949c1de987814 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 15:54:54 -0600 Subject: [PATCH] feat: save play time as live did: time before the session, stime in it Live kept the total before the running session in and the session's seconds in ; both changed at a login, together with llog (time 262486 and stime 7344 became time 269830 at the next login). The client reads both (LWOCharacterComponent::LoadFromSaveData 0x00ca4910) and shows their sum (SendMessage 0x00d34330, GetTimePlayed). The server wrote everything into time and no stime. Now time + stime is read (a missing stime is 0, so a save from before keeps its total) and time and stime are written as live had them. A session starts when the character is picked on the character list: charinfo.last_login is loaded with the character, a save whose llog is another login has its stime moved into time, and llog is written as the session's login. Without a known login the session goes on. GetTotalTimePlayed is still every second played. The dashboard's save check knows stime as a number. Co-Authored-By: Claude Fable 5.2 --- dDashboardServer/routes/CharacterXmlCheck.h | 2 +- dDatabase/GameDatabase/ITables/ICharInfo.h | 2 + .../GameDatabase/MySQL/Tables/CharInfo.cpp | 5 +- .../GameDatabase/SQLite/Tables/CharInfo.cpp | 5 +- dGame/Character.cpp | 1 + dGame/Character.h | 12 ++ .../Character/CharacterComponent.cpp | 32 +++-- .../Character/CharacterComponent.h | 12 ++ .../CharacterSaveFieldsTests.cpp | 119 +++++++++++++++++- 9 files changed, 175 insertions(+), 15 deletions(-) diff --git a/dDashboardServer/routes/CharacterXmlCheck.h b/dDashboardServer/routes/CharacterXmlCheck.h index 69ffe9cbf..0a83cbc6e 100644 --- a/dDashboardServer/routes/CharacterXmlCheck.h +++ b/dDashboardServer/routes/CharacterXmlCheck.h @@ -135,7 +135,7 @@ namespace CharacterXmlCheck { Numbers(c, where, eKind::INT, { "gm", "ft" }); Numbers(c, where, eKind::UINT, { "lwid", "lnzid", "acct" }); Numbers(c, where, eKind::UINT64, { "lzid", "co", "co1", "co2", "co3", "time" }); - Numbers(c, where, eKind::FLOAT, { "lzx", "lzy", "lzz", "lzrx", "lzry", "lzrz", "lzrw" }); + Numbers(c, where, eKind::FLOAT, { "lzx", "lzy", "lzz", "lzrx", "lzry", "lzrz", "lzrw", "stime" }); if (c.Attribute("acct") && Parses(c, "acct", eKind::UINT) && c.UnsignedAttribute("acct") != context.ownerAccountId) { Error(where + ": acct=\"" + c.Attribute("acct") + "\" is not the account of this character (" + std::to_string(context.ownerAccountId) + ")"); diff --git a/dDatabase/GameDatabase/ITables/ICharInfo.h b/dDatabase/GameDatabase/ITables/ICharInfo.h index 2d8654746..a92479199 100644 --- a/dDatabase/GameDatabase/ITables/ICharInfo.h +++ b/dDatabase/GameDatabase/ITables/ICharInfo.h @@ -20,6 +20,8 @@ public: bool needsRename{}; LWOCLONEID cloneId{}; ePermissionMap permissionMap{}; + // charinfo.last_login: when the character was last picked on the character list (unix seconds) + uint64_t lastLogin{}; }; // Get the approved names of all characters. diff --git a/dDatabase/GameDatabase/MySQL/Tables/CharInfo.cpp b/dDatabase/GameDatabase/MySQL/Tables/CharInfo.cpp index edeb762bd..feb1214e4 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/CharInfo.cpp +++ b/dDatabase/GameDatabase/MySQL/Tables/CharInfo.cpp @@ -27,17 +27,18 @@ std::optional CharInfoFromQueryResult(PreparedStmtResultSet& st toReturn.cloneId = stmt->getUInt64("prop_clone_id"); toReturn.accountId = stmt->getUInt("account_id"); toReturn.permissionMap = static_cast(stmt->getUInt("permission_map")); + toReturn.lastLogin = stmt->getUInt64("last_login"); return toReturn; } std::optional MySQLDatabase::GetCharacterInfo(const LWOOBJID charId) { - auto result = ExecuteSelect("SELECT name, pending_name, needs_rename, prop_clone_id, permission_map, id, account_id FROM charinfo WHERE id = ? LIMIT 1;", charId); + auto result = ExecuteSelect("SELECT name, pending_name, needs_rename, prop_clone_id, permission_map, id, account_id, last_login FROM charinfo WHERE id = ? LIMIT 1;", charId); return CharInfoFromQueryResult(result); } std::optional MySQLDatabase::GetCharacterInfo(const std::string_view name) { - auto result = ExecuteSelect("SELECT name, pending_name, needs_rename, prop_clone_id, permission_map, id, account_id FROM charinfo WHERE name = ? LIMIT 1;", name); + auto result = ExecuteSelect("SELECT name, pending_name, needs_rename, prop_clone_id, permission_map, id, account_id, last_login FROM charinfo WHERE name = ? LIMIT 1;", name); return CharInfoFromQueryResult(result); } diff --git a/dDatabase/GameDatabase/SQLite/Tables/CharInfo.cpp b/dDatabase/GameDatabase/SQLite/Tables/CharInfo.cpp index ee540795b..42e671ac4 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/CharInfo.cpp +++ b/dDatabase/GameDatabase/SQLite/Tables/CharInfo.cpp @@ -28,19 +28,20 @@ std::optional CharInfoFromQueryResult(CppSQLite3Query stmt) { toReturn.cloneId = stmt.getInt64Field("prop_clone_id"); toReturn.accountId = stmt.getIntField("account_id"); toReturn.permissionMap = static_cast(stmt.getIntField("permission_map")); + toReturn.lastLogin = static_cast(stmt.getInt64Field("last_login")); return toReturn; } std::optional SQLiteDatabase::GetCharacterInfo(const LWOOBJID charId) { return CharInfoFromQueryResult( - ExecuteSelect("SELECT name, pending_name, needs_rename, prop_clone_id, permission_map, id, account_id FROM charinfo WHERE id = ? LIMIT 1;", charId).second + ExecuteSelect("SELECT name, pending_name, needs_rename, prop_clone_id, permission_map, id, account_id, last_login FROM charinfo WHERE id = ? LIMIT 1;", charId).second ); } std::optional SQLiteDatabase::GetCharacterInfo(const std::string_view name) { return CharInfoFromQueryResult( - ExecuteSelect("SELECT name, pending_name, needs_rename, prop_clone_id, permission_map, id, account_id FROM charinfo WHERE name = ? LIMIT 1;", name).second + ExecuteSelect("SELECT name, pending_name, needs_rename, prop_clone_id, permission_map, id, account_id, last_login FROM charinfo WHERE name = ? LIMIT 1;", name).second ); } diff --git a/dGame/Character.cpp b/dGame/Character.cpp index 88efe347e..84131e0ed 100644 --- a/dGame/Character.cpp +++ b/dGame/Character.cpp @@ -54,6 +54,7 @@ void Character::UpdateInfoFromDatabase() { m_NameRejected = charInfo->needsRename; m_PropertyCloneID = charInfo->cloneId; m_PermissionMap = charInfo->permissionMap; + m_SessionLogin = charInfo->lastLogin; } // Load the xmlData now. Loading takes the character over: saves from any server that loaded it before are refused. diff --git a/dGame/Character.h b/dGame/Character.h index 0154c09a6..7d37f18e5 100644 --- a/dGame/Character.h +++ b/dGame/Character.h @@ -133,6 +133,13 @@ public: */ uint64_t GetLastLogin() const { return m_LastLogin; } + /** + * When this character was picked on the character list for the session it is in (charinfo.last_login, unix + * seconds); 0 when not known. Live saved it as and started a new session's play time there. + */ + uint64_t GetSessionLogin() const { return m_SessionLogin; } + void SetSessionLogin(const uint64_t value) { m_SessionLogin = value; } + /** * Gets the default shirt color for this character @@ -693,6 +700,11 @@ private: */ uint64_t m_LastLogin{}; + /** + * charinfo.last_login as it was when this character was loaded + */ + uint64_t m_SessionLogin{}; + /** * Flags only set for the duration of a session * diff --git a/dGame/dComponents/Character/CharacterComponent.cpp b/dGame/dComponents/Character/CharacterComponent.cpp index e43e3ce78..9ae98e12e 100644 --- a/dGame/dComponents/Character/CharacterComponent.cpp +++ b/dGame/dComponents/Character/CharacterComponent.cpp @@ -81,7 +81,7 @@ bool CharacterComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo cmptType.PushDebug("Component ID") = GeneralUtils::ToUnderlying(ComponentType); cmptType.PushDebug("Character's account ID") = m_Character->GetParentUser()->GetAccountID(); cmptType.PushDebug("Last log out time") = m_Character->GetLastLogin(); - cmptType.PushDebug("Seconds played this session") = 0; + cmptType.PushDebug("Seconds played this session") = m_SessionTimePlayed; cmptType.PushDebug("Editor enabled") = false; cmptType.PushDebug("Total number of seconds played") = m_TotalTimePlayed; cmptType.PushDebug("Total currency") = std::to_string(m_Character->GetCoins()); @@ -398,11 +398,22 @@ void CharacterComponent::LoadFromSaveData(const tinyxml2::XMLDocument& doc) { LoadGuild(); - if (character->FindAttribute("time")) { - character->QueryUnsigned64Attribute("time", &m_TotalTimePlayed); - } else { - m_TotalTimePlayed = 0; - } + // Play time as live saved it and the client reads it (LWOCharacterComponent::LoadFromSaveData 0x00ca4910): time is + // the total before the session, stime the seconds of the session so far; the client shows their sum (SendMessage + // 0x00d34330, GetTimePlayed). Saves from before stime was written hold everything in time. + uint64_t timeBeforeSession = 0; + character->QueryUnsigned64Attribute("time", &timeBeforeSession); + double savedSessionTime = 0.0; // the client reads a float + character->QueryDoubleAttribute("stime", &savedSessionTime); + m_SessionTimePlayed = savedSessionTime > 0.0 ? static_cast(savedSessionTime) : 0; + m_TotalTimePlayed = timeBeforeSession + m_SessionTimePlayed; + + // A session starts when the character is picked on the character list; live's llog (and time) changed there and + // stayed over the session's world transfers. A save of another login: its session is over, a new one starts at 0. + uint64_t savedSessionLogin = 0; + character->QueryUnsigned64Attribute("llog", &savedSessionLogin); + const auto sessionLogin = m_Character ? m_Character->GetSessionLogin() : 0; + if (sessionLogin != 0 && sessionLogin != savedSessionLogin) m_SessionTimePlayed = 0; if (!m_Character) return; @@ -530,8 +541,13 @@ void CharacterComponent::WriteSaveData(tinyxml2::XMLDocument& doc) { auto newUpdateTimestamp = std::time(nullptr); LOG("Time since last save: %d", newUpdateTimestamp - m_LastUpdateTimestamp); - m_TotalTimePlayed += newUpdateTimestamp - m_LastUpdateTimestamp; - character->SetAttribute("time", m_TotalTimePlayed); + const auto sinceLastSave = newUpdateTimestamp > m_LastUpdateTimestamp ? static_cast(newUpdateTimestamp - m_LastUpdateTimestamp) : 0; + m_TotalTimePlayed += sinceLastSave; + m_SessionTimePlayed += sinceLastSave; + // As live: the total before this session and the session's seconds (see LoadFromSaveData); their sum is the total + character->SetAttribute("time", m_TotalTimePlayed - m_SessionTimePlayed); + character->SetAttribute("stime", m_SessionTimePlayed); + if (m_Character && m_Character->GetSessionLogin() != 0) character->SetAttribute("llog", m_Character->GetSessionLogin()); m_LastUpdateTimestamp = newUpdateTimestamp; } diff --git a/dGame/dComponents/Character/CharacterComponent.h b/dGame/dComponents/Character/CharacterComponent.h index 15c7883e7..a1770744e 100644 --- a/dGame/dComponents/Character/CharacterComponent.h +++ b/dGame/dComponents/Character/CharacterComponent.h @@ -373,8 +373,15 @@ public: int32_t GetVotesLeft() const { return m_VotesLeft; } uint64_t GetVoteDay() const { return m_VoteDay; } + // Every second played, the running session included (saved as + ) uint64_t GetTotalTimePlayed() const { return m_TotalTimePlayed; }; + // The seconds played since the character was picked on the character list (saved as ) + uint64_t GetSessionTimePlayed() const { return m_SessionTimePlayed; }; + + // When the play time was last counted (construction, then every save); tests move it back to let time pass + void SetLastUpdateTimestamp(const time_t value) { m_LastUpdateTimestamp = value; } + /** * Character info regarding this character, including clothing styles, etc. */ @@ -533,6 +540,11 @@ private: */ uint64_t m_TotalTimePlayed; + /** + * The part of m_TotalTimePlayed that was played in the running session, in seconds + */ + uint64_t m_SessionTimePlayed = 0; + /** * The total amount of currency collected by this character */ diff --git a/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp b/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp index 723df45a5..3c3357804 100644 --- a/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp +++ b/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp @@ -144,8 +144,10 @@ TEST_F(CharacterSaveFieldsTests, LiveCharacterAttributesRoundTrip) { // No respawn point was reached in this world: the saved one stays EXPECT_STREQ(saved->Attribute("lrx"), "1"); EXPECT_STREQ(saved->Attribute("lrrw"), "0.5"); + // The session goes on (no other login is known): its seconds are kept + EXPECT_GE(saved->Unsigned64Attribute("stime"), 349u); + EXPECT_LE(saved->Unsigned64Attribute("stime"), 351u); // Not written by the server: left alone - EXPECT_STREQ(saved->Attribute("stime"), "349"); EXPECT_STREQ(saved->Attribute("lzcs"), "2233038349"); // The same day: the votes left are kept @@ -188,7 +190,120 @@ TEST_F(CharacterSaveFieldsTests, LiveCharacterAttributesMissingInOldSave) { EXPECT_STREQ(saved->Attribute("v"), "3"); EXPECT_NE(saved->Attribute("vd"), nullptr); EXPECT_EQ(saved->Attribute("lrx"), nullptr); // no respawn point reached yet - EXPECT_EQ(saved->Attribute("stime"), nullptr); + EXPECT_NE(saved->Attribute("stime"), nullptr); +} + +namespace { + // Loads with the given attributes for a character whose login (charinfo.last_login) is sessionLogin, lets + // seconds pass and saves. Returns the saved . + const tinyxml2::XMLElement* LoadPlayAndSave(tinyxml2::XMLDocument& doc, Character& character, CharacterComponent& component, + const std::string& attributes, const uint64_t sessionLogin, const int64_t seconds) { + const auto xml = R"("; + EXPECT_EQ(doc.Parse(xml.c_str()), tinyxml2::XML_SUCCESS); + character.SetSessionLogin(sessionLogin); + component.LoadFromSaveData(doc); + component.SetLastUpdateTimestamp(std::time(nullptr) - seconds); + component.WriteSaveData(doc); + return doc.FirstChildElement("obj")->FirstChildElement("char"); + } + + // The seconds asked for, or one more when the clock ticked during the test + void ExpectAbout(const uint64_t value, const uint64_t expected) { + EXPECT_GE(value, expected); + EXPECT_LE(value, expected + 1); + } +} + +// Play time as live saved it: time is the total before the session, stime the session's seconds, and the client +// shows time + stime (LWOCharacterComponent::LoadFromSaveData 0x00ca4910, SendMessage 0x00d34330 GetTimePlayed). +// A save from before stime was written has every past session in time: the total stays what it was. +TEST_F(CharacterSaveFieldsTests, OldSavePlayTimeKeepsItsTotal) { + tinyxml2::XMLDocument doc; + Character character(1, nullptr); + Entity player(60, info); + auto* const component = player.AddComponent(-1, &character, UNASSIGNED_SYSTEM_ADDRESS); + const auto* const saved = LoadPlayAndSave(doc, character, *component, R"(llog="1700000000" time="54390")", 1700090000, 0); + EXPECT_EQ(component->GetTotalTimePlayed() - component->GetSessionTimePlayed(), 54390u); + ExpectAbout(component->GetTotalTimePlayed(), 54390); + EXPECT_STREQ(saved->Attribute("time"), "54390"); + ExpectAbout(saved->Unsigned64Attribute("stime"), 0); + EXPECT_STREQ(saved->Attribute("llog"), "1700090000"); + EXPECT_EQ(saved->Unsigned64Attribute("time") + saved->Unsigned64Attribute("stime"), component->GetTotalTimePlayed()); + + // The same without a known login (no charinfo row): nothing is lost and llog is left alone + tinyxml2::XMLDocument unknownDoc; + Character unknown(2, nullptr); + Entity other(61, info); + auto* const otherComponent = other.AddComponent(-1, &unknown, UNASSIGNED_SYSTEM_ADDRESS); + const auto* const otherSaved = LoadPlayAndSave(unknownDoc, unknown, *otherComponent, R"(llog="1700000000" time="54390")", 0, 0); + EXPECT_STREQ(otherSaved->Attribute("time"), "54390"); + EXPECT_STREQ(otherSaved->Attribute("llog"), "1700000000"); + ExpectAbout(otherComponent->GetTotalTimePlayed(), 54390); +} + +// A session in progress: the seconds since the last save go to stime, time stays, and a world transfer in the same +// session (the same login) goes on counting in stime. Live: time=262486 with stime=6795, then 7259 in the next world. +TEST_F(CharacterSaveFieldsTests, SessionPlayTimeGrowsInStime) { + tinyxml2::XMLDocument doc; + Character character(1, nullptr); + Entity player(62, info); + auto* const component = player.AddComponent(-1, &character, UNASSIGNED_SYSTEM_ADDRESS); + const auto* saved = LoadPlayAndSave(doc, character, *component, R"(llog="1327837739" time="262486" stime="6795")", 1327837739, 464); + EXPECT_STREQ(saved->Attribute("time"), "262486"); + ExpectAbout(saved->Unsigned64Attribute("stime"), 7259); + EXPECT_STREQ(saved->Attribute("llog"), "1327837739"); + ExpectAbout(component->GetTotalTimePlayed(), 262486 + 7259); + ExpectAbout(component->GetSessionTimePlayed(), 7259); + + // A second save in the same world adds only what passed since the first + component->SetLastUpdateTimestamp(std::time(nullptr) - 10); + component->WriteSaveData(doc); + EXPECT_STREQ(saved->Attribute("time"), "262486"); + EXPECT_GE(saved->Unsigned64Attribute("stime"), 7269u); + EXPECT_LE(saved->Unsigned64Attribute("stime"), 7271u); + + // The next world of the session loads what this one saved and goes on + tinyxml2::XMLPrinter printer(nullptr, true); + doc.Print(&printer); + const std::string written = printer.CStr(); + tinyxml2::XMLDocument next; + ASSERT_EQ(next.Parse(written.c_str()), tinyxml2::XML_SUCCESS); + Entity inNextWorld(63, info); + auto* const nextComponent = inNextWorld.AddComponent(-1, &character, UNASSIGNED_SYSTEM_ADDRESS); + nextComponent->LoadFromSaveData(next); + EXPECT_EQ(nextComponent->GetTotalTimePlayed(), component->GetTotalTimePlayed()); + EXPECT_EQ(nextComponent->GetSessionTimePlayed(), component->GetSessionTimePlayed()); + nextComponent->SetLastUpdateTimestamp(std::time(nullptr) - 30); + nextComponent->WriteSaveData(next); + const auto* const nextSaved = next.FirstChildElement("obj")->FirstChildElement("char"); + EXPECT_STREQ(nextSaved->Attribute("time"), "262486"); + EXPECT_GE(nextSaved->Unsigned64Attribute("stime"), 7299u); + EXPECT_LE(nextSaved->Unsigned64Attribute("stime"), 7302u); +} + +// The next login: the last session's seconds join time and stime starts over. Live: time=262486 stime=7344 at the +// end of one session, time=269830 in the next. The total is the same before and after. +TEST_F(CharacterSaveFieldsTests, ANewSessionMovesStimeIntoTime) { + tinyxml2::XMLDocument doc; + Character character(1, nullptr); + Entity player(64, info); + auto* const component = player.AddComponent(-1, &character, UNASSIGNED_SYSTEM_ADDRESS); + const auto* const saved = LoadPlayAndSave(doc, character, *component, R"(llog="1327837739" time="262486" stime="7344")", 1327848273, 74); + EXPECT_STREQ(saved->Attribute("time"), "269830"); + ExpectAbout(saved->Unsigned64Attribute("stime"), 74); + EXPECT_STREQ(saved->Attribute("llog"), "1327848273"); + ExpectAbout(component->GetTotalTimePlayed(), 269830 + 74); +} + +// The client reads stime as a float; a fraction written by something else is read, not refused +TEST_F(CharacterSaveFieldsTests, FractionalStimeIsRead) { + tinyxml2::XMLDocument doc; + Character character(1, nullptr); + Entity player(65, info); + auto* const component = player.AddComponent(-1, &character, UNASSIGNED_SYSTEM_ADDRESS); + const auto* const saved = LoadPlayAndSave(doc, character, *component, R"(time="100" stime="15.6958")", 0, 0); + EXPECT_STREQ(saved->Attribute("time"), "100"); + ExpectAbout(saved->Unsigned64Attribute("stime"), 15); } // : the cooldown groups still running and the seconds left, as live wrote them (sc="17:15.6958;") and the