mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-11-14 07:47:13 +00:00
feat: save play time as live did: time before the session, stime in it
Live kept the total before the running session in <char time> and the session's seconds in <char stime>; 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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) + ")");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -27,17 +27,18 @@ std::optional<ICharInfo::Info> CharInfoFromQueryResult(PreparedStmtResultSet& st
|
||||
toReturn.cloneId = stmt->getUInt64("prop_clone_id");
|
||||
toReturn.accountId = stmt->getUInt("account_id");
|
||||
toReturn.permissionMap = static_cast<ePermissionMap>(stmt->getUInt("permission_map"));
|
||||
toReturn.lastLogin = stmt->getUInt64("last_login");
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
std::optional<ICharInfo::Info> 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<ICharInfo::Info> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,19 +28,20 @@ std::optional<ICharInfo::Info> CharInfoFromQueryResult(CppSQLite3Query stmt) {
|
||||
toReturn.cloneId = stmt.getInt64Field("prop_clone_id");
|
||||
toReturn.accountId = stmt.getIntField("account_id");
|
||||
toReturn.permissionMap = static_cast<ePermissionMap>(stmt.getIntField("permission_map"));
|
||||
toReturn.lastLogin = static_cast<uint64_t>(stmt.getInt64Field("last_login"));
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
std::optional<ICharInfo::Info> 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<ICharInfo::Info> 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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <char llog> 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
|
||||
*
|
||||
|
||||
@@ -81,7 +81,7 @@ bool CharacterComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo
|
||||
cmptType.PushDebug<AMFIntValue>("Component ID") = GeneralUtils::ToUnderlying(ComponentType);
|
||||
cmptType.PushDebug<AMFIntValue>("Character's account ID") = m_Character->GetParentUser()->GetAccountID();
|
||||
cmptType.PushDebug<AMFBoolValue>("Last log out time") = m_Character->GetLastLogin();
|
||||
cmptType.PushDebug<AMFDoubleValue>("Seconds played this session") = 0;
|
||||
cmptType.PushDebug<AMFDoubleValue>("Seconds played this session") = m_SessionTimePlayed;
|
||||
cmptType.PushDebug<AMFBoolValue>("Editor enabled") = false;
|
||||
cmptType.PushDebug<AMFDoubleValue>("Total number of seconds played") = m_TotalTimePlayed;
|
||||
cmptType.PushDebug<AMFStringValue>("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<uint64_t>(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<uint64_t>(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;
|
||||
}
|
||||
|
||||
@@ -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 <char time> + <char stime>)
|
||||
uint64_t GetTotalTimePlayed() const { return m_TotalTimePlayed; };
|
||||
|
||||
// The seconds played since the character was picked on the character list (saved as <char stime>)
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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 <char> with the given attributes for a character whose login (charinfo.last_login) is sessionLogin, lets
|
||||
// seconds pass and saves. Returns the saved <char>.
|
||||
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"(<obj v="1"><mf/><char acct="1" cc="5" )" + attributes + "/></obj>";
|
||||
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<CharacterComponent>(-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<CharacterComponent>(-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<CharacterComponent>(-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<CharacterComponent>(-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<CharacterComponent>(-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<CharacterComponent>(-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);
|
||||
}
|
||||
|
||||
// <skil sc>: the cooldown groups still running and the seconds left, as live wrote them (sc="17:15.6958;") and the
|
||||
|
||||
Reference in New Issue
Block a user