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:
Aaron Kimbrell
2026-11-10 15:54:54 -06:00
parent baf8b24ad1
commit b2fba9d35b
9 changed files with 175 additions and 15 deletions

View File

@@ -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) + ")");

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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
);
}

View File

@@ -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.

View File

@@ -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
*

View File

@@ -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;
}

View File

@@ -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
*/

View File

@@ -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