diff --git a/src/game/chars/CChar.cpp b/src/game/chars/CChar.cpp index cb55c579a..a5953aa3b 100644 --- a/src/game/chars/CChar.cpp +++ b/src/game/chars/CChar.cpp @@ -3462,26 +3462,45 @@ bool CChar::r_LoadVal( CScript & s ) return CObjBase::r_LoadVal( s ); } - switch (iKeyNum) - { - //Status Update Variables + switch (iKeyNum) + { + case CHC_DEATHTXN: + { + int64 values[8] {}; + if (Str_ParseCmds(s.GetArgStr(), values, ARRAY_COUNT(values)) != ARRAY_COUNT(values)) + return false; + if (values[0] != CCharDeathTransaction::kSaveVersion || values[1] <= (int64)CCharDeathPhase::None || + values[1] > (int64)CCharDeathPhase::EmergencyFinalization) + return false; + m_deathTransaction.phase = (CCharDeathPhase)values[1]; + m_deathTransaction.transactionId = (uint32)values[2]; + m_deathTransaction.killerUid = (uint32)values[3]; + m_deathTransaction.corpseUid = (uint32)values[4]; + m_deathTransaction.dispatchedCallbacks = (uint32)values[5]; + m_deathTransaction.retryCount = (uint16)values[6]; + m_deathTransaction.frontFall = (values[7] & 1) != 0; + m_deathTransaction.legacyRecovery = (values[7] & 2) != 0; + m_deathTransaction.nextRetryTime = 0; + break; + } + //Status Update Variables case CHC_REGENHITS: - Stats_SetRegenRate(STAT_STR, s.GetArg64Val()* MSECS_PER_SEC); + Stats_SetRegenRate(STAT_STR, s.GetArg64Val() * MSECS_PER_SEC); break; case CHC_REGENHITSD: - Stats_SetRegenRate(STAT_STR, s.GetArg64Val()* MSECS_PER_TENTH); + Stats_SetRegenRate(STAT_STR, s.GetArg64Val() * MSECS_PER_TENTH); break; case CHC_REGENSTAM: - Stats_SetRegenRate(STAT_DEX, s.GetArg64Val()* MSECS_PER_SEC); + Stats_SetRegenRate(STAT_DEX, s.GetArg64Val() * MSECS_PER_SEC); break; case CHC_REGENSTAMD: - Stats_SetRegenRate(STAT_DEX, s.GetArg64Val()* MSECS_PER_TENTH); + Stats_SetRegenRate(STAT_DEX, s.GetArg64Val() * MSECS_PER_TENTH); break; case CHC_REGENMANA: - Stats_SetRegenRate(STAT_INT, s.GetArg64Val()* MSECS_PER_SEC); + Stats_SetRegenRate(STAT_INT, s.GetArg64Val() * MSECS_PER_SEC); break; case CHC_REGENMANAD: - Stats_SetRegenRate(STAT_INT, s.GetArg64Val()* MSECS_PER_TENTH); + Stats_SetRegenRate(STAT_INT, s.GetArg64Val() * MSECS_PER_TENTH); break; case CHC_REGENFOOD: Stats_SetRegenRate(STAT_FOOD, s.GetArg64Val()* MSECS_PER_SEC); @@ -4096,44 +4115,52 @@ bool CChar::r_LoadVal( CScript & s ) break; default: return false; - } - return true; + } + return true; EXC_CATCH; EXC_DEBUG_START; EXC_ADD_SCRIPT; - EXC_DEBUG_END; - return false; + EXC_DEBUG_END; + return false; } -void CChar::r_Write( CScript & s ) +void CChar::r_Write(CScript &s) { - ADDTOCALLSTACK("CChar::r_Write"); - EXC_TRY("r_Write"); + ADDTOCALLSTACK("CChar::r_Write"); + EXC_TRY("r_Write"); - s.WriteSection("WORLDCHAR %s", GetResourceName()); - s.WriteKeyVal("CREATE", CWorldGameTime::GetCurrentTime().GetTimeDiff(_iTimeCreate) / MSECS_PER_TENTH ); + s.WriteSection("WORLDCHAR %s", GetResourceName()); + s.WriteKeyVal("CREATE", CWorldGameTime::GetCurrentTime().GetTimeDiff(_iTimeCreate) / MSECS_PER_TENTH); // Do not save TAG.LastHit (used by PreHit combat flag). It's based on the server uptime, so if this tag isn't zeroed, // after the server restart the char may not be able to attack until the server reaches the serv.time when the previous TAG.LastHit was set. - int64 iValLastHit = 0; - CVarDefContNum* pVarLastHit = m_TagDefs.GetKeyDefNum("LastHit"); - if (pVarLastHit) - { - iValLastHit = pVarLastHit->GetValNum(); - pVarLastHit->SetValNum(0); - } + int64 iValLastHit = 0; + CVarDefContNum *pVarLastHit = m_TagDefs.GetKeyDefNum("LastHit"); + if (pVarLastHit) + { + iValLastHit = pVarLastHit->GetValNum(); + pVarLastHit->SetValNum(0); + } - CObjBase::r_Write(s); + CObjBase::r_Write(s); + if (m_deathTransaction.IsActive()) + { + const uint flags = (m_deathTransaction.frontFall ? 1u : 0u) | (m_deathTransaction.legacyRecovery ? 2u : 0u); + s.WriteKeyFormat("DEATHTXN", "%u,%u,%" PRIu32 ",0%" PRIx32 ",0%" PRIx32 ",0%" PRIx32 ",%u,%u", CCharDeathTransaction::kSaveVersion, + (uint)m_deathTransaction.phase, m_deathTransaction.transactionId, m_deathTransaction.killerUid, m_deathTransaction.corpseUid, + m_deathTransaction.dispatchedCallbacks, m_deathTransaction.retryCount, flags); + } - for (uint i = 0; auto const& fdata : m_followers) { + for (uint i = 0; auto const &fdata : m_followers) + { char *pcTag = Str_GetTemp(); snprintf(pcTag, Str_TempLength(), "FOLLOWER.%u", i); s.WriteKeyFormat(pcTag, "0%" PRIx32 ",%d", fdata.uid.GetObjUID(), fdata.followerslots); - ++ i; + ++i; } - if (iValLastHit != 0) + if (iValLastHit != 0) { pVarLastHit->SetValNum(iValLastHit); } @@ -5358,4 +5385,3 @@ uint CChar::GetSkillTotal(int what, bool how) return uiTotal; } - diff --git a/src/game/chars/CChar.h b/src/game/chars/CChar.h index f3e83be33..a4a3fb3d9 100644 --- a/src/game/chars/CChar.h +++ b/src/game/chars/CChar.h @@ -13,6 +13,7 @@ #include "../CTimedObject.h" #include "../game_macros.h" #include "CCharBase.h" +#include "CCharDeathState.h" #include "CCharPlayer.h" @@ -152,6 +153,7 @@ class CChar : public CObjBase, public CContainer, public CTextConsole height_t m_zClimbHeight; // The height at the end of the climbable. // Saved stuff. + CCharDeathTransaction m_deathTransaction; byte m_iVisualRange; // Visual Range height_t m_height; // Height set in-game or under some trigger (height=) - for both items and chars HUE_TYPE _wBloodHue; // Replicating CharDef's BloodColor on the char, or overriding it. @@ -1221,12 +1223,15 @@ public: void StatFlag_Mod(uint64 uiStatFlag, bool fMod) noexcept; { Success, SuccessAndDelete, + InProgress, AlreadyDead, Aborted, AbortedNoLog }; DeathRequestResult Death(); + DeathRequestResult ContinueDeath(); + bool HasActiveDeathTransaction() const noexcept { return m_deathTransaction.IsActive(); } /** * @Brief I'm calling guards (Player speech) diff --git a/src/game/chars/CCharAct.cpp b/src/game/chars/CCharAct.cpp index c51cc098b..9b78680a0 100644 --- a/src/game/chars/CCharAct.cpp +++ b/src/game/chars/CCharAct.cpp @@ -4328,222 +4328,356 @@ void CChar::SleepStart( bool fFrontFall ) // Removing myself from view, generating Death packets. CChar::DeathRequestResult CChar::Death() { - ADDTOCALLSTACK("CChar::Death"); + ADDTOCALLSTACK("CChar::Death"); + EXC_TRY("Death transaction"); - if ( IsStatFlag(STATF_DEAD) ) - return DeathRequestResult::AlreadyDead; + CCharDeathTransaction &tx = m_deathTransaction; + if (tx.executing) + return DeathRequestResult::InProgress; + CCharDeathExecutionGuard executionGuard(tx); + if (tx.phase == CCharDeathPhase::Completed) + return (m_pNPC && !m_pNPC->m_bonded) ? DeathRequestResult::SuccessAndDelete : DeathRequestResult::AlreadyDead; - if ( IsStatFlag(STATF_INVUL) ) - return DeathRequestResult::AbortedNoLog; + if (CCharShouldStartLegacyDeathRecovery(IsStatFlag(STATF_DEAD), m_pNPC != nullptr, m_pNPC && m_pNPC->m_bonded, tx.IsActive())) + { + tx.phase = CCharDeathPhase::DeadCommitted; + tx.transactionId = GetUID().GetObjUID(); + tx.legacyRecovery = true; + tx.dispatchedCallbacks = UINT32_MAX; + g_Log.EventWarn("DeathTxn legacy-recovery uid=0%" PRIx32 ".\n", GetUID().GetObjUID()); + } + else if (IsStatFlag(STATF_DEAD) && !tx.IsActive()) + { + return DeathRequestResult::AlreadyDead; + } - if ( IsTrigUsed(TRIGGER_DEATH) ) - { - if ( OnTrigger(CTRIG_Death, CScriptParserBufs::GetCScriptTriggerArgsPtr(), this) == TRIGRET_RET_TRUE ) + if (!tx.IsActive()) + { + if (IsStatFlag(STATF_INVUL)) + return DeathRequestResult::AbortedNoLog; + tx.phase = CCharDeathPhase::Accepted; + tx.transactionId = GetUID().GetObjUID() ^ (uint32)CWorldGameTime::GetCurrentTime().GetTimeRaw(); + tx.frontFall = g_Rand.GetValFast(2); + g_Log.EventDebug("DeathTxn start uid=0%" PRIx32 " tx=%" PRIu32 " hits=%u flags=0%" PRIx64 ".\n", GetUID().GetObjUID(), tx.transactionId, + Stat_GetVal(STAT_STR), _uiStatFlag); + } + if (tx.phase == CCharDeathPhase::Accepted && tx.DispatchOnce(CCharDeathCallback::Death) && IsTrigUsed(TRIGGER_DEATH)) + { + if (OnTrigger(CTRIG_Death, CScriptParserBufs::GetCScriptTriggerArgsPtr(), this) == TRIGRET_RET_TRUE) + { + tx.Reset(); return DeathRequestResult::Aborted; - } - //Dismount now. Later is may be too late and cause problems - if ( m_pNPC ) - { - if (Skill_GetActive() == NPCACT_RIDDEN) - { - CChar* pCRider = Horse_GetMountChar(); - if (pCRider) - pCRider->Horse_UnMount(); - } - } - // Look through memories of who I was fighting (make sure they knew they where fighting me) - for (CSObjContRec* pObjRec : GetIterationSafeContReverse()) - { - CItem* pItem = static_cast(pObjRec); - if ( pItem->IsType(IT_EQ_TRADE_WINDOW) ) - { - CItemContainer *pCont = dynamic_cast(pItem); - if ( pCont ) - { - pCont->Trade_Delete(); - continue; - } - } + } + } - // Remove every memory, with some exceptions - if ( pItem->IsType(IT_EQ_MEMORY_OBJ) ) - Memory_ClearTypes( static_cast(pItem), (MEMORY_FIGHT | MEMORY_HARMEDBY) ); - } + if (tx.nextRetryTime > CWorldGameTime::GetCurrentTime().GetTimeRaw()) + return DeathRequestResult::InProgress; + if (tx.phase == CCharDeathPhase::EmergencyFinalization) + { + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::BeforePhase); + NPC_PetClearOwners(); + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::AfterSideEffect); + tx.phase = CCharDeathPhase::Completed; + return DeathRequestResult::SuccessAndDelete; + } - // Give credit for the kill to my attacker(s) - int iKillers = 0; - CChar * pKiller = nullptr; - tchar * pszKillStr = Str_GetTemp(); - int iKillStrLen = snprintf( pszKillStr, Str_TempLength(), g_Cfg.GetDefaultMsg(DEFMSG_MSG_KILLED_BY), (m_pPlayer)? 'P':'N', GetNameWithoutIncognito() ); - for ( size_t count = 0; count < m_lastAttackers.size(); ++count ) - { - pKiller = CUID::CharFindFromUID(m_lastAttackers[count].charUID); - if ( pKiller && (m_lastAttackers[count].amountDone > 0) ) - { - if ( IsTrigUsed(TRIGGER_KILL) ) - { - CScriptTriggerArgsPtr pScriptArgs = CScriptParserBufs::GetCScriptTriggerArgsPtr(); - pScriptArgs->Init(GetAttackersCount(), 0, 0, this); - if ( pKiller->OnTrigger(CTRIG_Kill, pScriptArgs, pKiller) == TRIGRET_RET_TRUE ) - continue; - } + if (tx.phase == CCharDeathPhase::Accepted) + { + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::BeforePhase); + //Dismount now. Later is may be too late and cause problems + if (m_pNPC) + { + if (Skill_GetActive() == NPCACT_RIDDEN) + { + CChar *pCRider = Horse_GetMountChar(); + if (pCRider) + pCRider->Horse_UnMount(); + } + } + // Look through memories of who I was fighting (make sure they knew they where fighting me) + for (CSObjContRec *pObjRec : GetIterationSafeContReverse()) + { + CItem *pItem = static_cast(pObjRec); + if (pItem->IsType(IT_EQ_TRADE_WINDOW)) + { + CItemContainer *pCont = dynamic_cast(pItem); + if (pCont) + { + pCont->Trade_Delete(); + continue; + } + } + + // Remove every memory, with some exceptions + if (pItem->IsType(IT_EQ_MEMORY_OBJ)) + Memory_ClearTypes(static_cast(pItem), (MEMORY_FIGHT | MEMORY_HARMEDBY)); + } + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::AfterSideEffect); + tx.phase = CCharDeathPhase::InteractionsDetached; + tx.retryCount = 0; + } - pKiller->Noto_Kill( this, GetAttackersCount() ); + // Give credit for the kill to my attacker(s) + CChar *pKiller = CUID::CharFindFromUID(tx.killerUid); + if (tx.phase == CCharDeathPhase::InteractionsDetached) + { + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::BeforePhase); + tx.phase = CCharDeathPhase::AttributionDispatched; + tx.retryCount = 0; + tx.dispatchedCallbacks |= CCharDeathCallback::Attribution; + int iKillers = 0; + tchar *pszKillStr = Str_GetTemp(); + int iKillStrLen = snprintf(pszKillStr, Str_TempLength(), g_Cfg.GetDefaultMsg(DEFMSG_MSG_KILLED_BY), (m_pPlayer) ? 'P' : 'N', GetNameWithoutIncognito()); + for (size_t count = 0; count < m_lastAttackers.size(); ++count) + { + pKiller = CUID::CharFindFromUID(m_lastAttackers[count].charUID); + if (pKiller && (m_lastAttackers[count].amountDone > 0)) + { + tx.killerUid = pKiller->GetUID().GetObjUID(); + if (IsTrigUsed(TRIGGER_KILL)) + { + CScriptTriggerArgsPtr pScriptArgs = CScriptParserBufs::GetCScriptTriggerArgsPtr(); + pScriptArgs->Init(GetAttackersCount(), 0, 0, this); + if (pKiller->OnTrigger(CTRIG_Kill, pScriptArgs, pKiller) == TRIGRET_RET_TRUE) + continue; + } - iKillStrLen += snprintf( - pszKillStr + iKillStrLen, Str_TempLength() - iKillStrLen, - "%s%c'%s'.", - iKillers ? ", " : "", - (pKiller->m_pPlayer) ? 'P':'N', pKiller->GetNameWithoutIncognito() ); + pKiller->Noto_Kill(this, GetAttackersCount()); - ++iKillers; - } - } + iKillStrLen += snprintf(pszKillStr + iKillStrLen, Str_TempLength() - iKillStrLen, "%s%c'%s'.", iKillers ? ", " : "", + (pKiller->m_pPlayer) ? 'P' : 'N', pKiller->GetNameWithoutIncognito()); - // Record the kill event for posterity - if ( !iKillers ) - /*iKillStrLen +=*/ snprintf( pszKillStr + iKillStrLen, Str_TempLength() - iKillStrLen, "accident." ); - if ( m_pPlayer ) - g_Log.Event( LOGL_EVENT|LOGM_KILLS, "%s\n", pszKillStr ); - if ( m_pParty ) - m_pParty->SysMessageAll( pszKillStr ); - - Reveal(); - SoundChar(CRESND_DIE); - StatFlag_Set(STATF_DEAD); - StatFlag_Clear(STATF_STONE|STATF_FREEZE|STATF_HIDDEN|STATF_SLEEPING|STATF_HOVERING); - SetPoisonCure(true); - Skill_Cleanup(); - Spell_Dispel(100); // get rid of all spell effects (moved here to prevent double @Destroy trigger) - - if ( m_pPlayer ) // if I'm NPC then my mount goes with me - Horse_UnMount(); - - if ( IsTrigUsed(TRIGGER_CREATELOOT) ) + ++iKillers; + } + } + + // Record the kill event for posterity + if (!iKillers) + /*iKillStrLen +=*/snprintf(pszKillStr + iKillStrLen, Str_TempLength() - iKillStrLen, "accident."); + if (m_pPlayer) + g_Log.Event(LOGL_EVENT | LOGM_KILLS, "%s\n", pszKillStr); + if (m_pParty) + m_pParty->SysMessageAll(pszKillStr); + CCharDeathFaultInjector::Check(CCharDeathPhase::InteractionsDetached, CCharDeathFaultBoundary::AfterSideEffect); + } + + if (tx.phase == CCharDeathPhase::AttributionDispatched) { - //OnTrigger(CTRIG_CreateLoot, this); - ReadScriptReducedTrig(Char_GetDef(), CTRIG_CreateLoot, false); + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::BeforePhase); + Reveal(); + SoundChar(CRESND_DIE); + StatFlag_Set(STATF_DEAD); + StatFlag_Clear(STATF_STONE | STATF_FREEZE | STATF_HIDDEN | STATF_SLEEPING | STATF_HOVERING); + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::AfterSideEffect); + tx.phase = CCharDeathPhase::DeadCommitted; + tx.retryCount = 0; } - // Create the corpse item - bool fFrontFall = g_Rand.GetValFast(2); - CItemCorpse * pCorpse = MakeCorpse(fFrontFall); - if ( pCorpse ) - { - if ( IsTrigUsed(TRIGGER_DEATHCORPSE) ) - { - CScriptTriggerArgsPtr pScriptArgs = CScriptParserBufs::GetCScriptTriggerArgsPtr(); - pScriptArgs->Init(pCorpse); - OnTrigger(CTRIG_DeathCorpse, pScriptArgs, this); - } - } + if (tx.phase == CCharDeathPhase::DeadCommitted) + { + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::BeforePhase); + SetPoisonCure(true); + Skill_Cleanup(); + Spell_Dispel(100); // get rid of all spell effects (moved here to prevent double @Destroy trigger) + + if (m_pPlayer) // if I'm NPC then my mount goes with me + Horse_UnMount(); + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::AfterSideEffect); + tx.phase = CCharDeathPhase::CoreEffectsCleaned; + tx.retryCount = 0; + } + + if (tx.phase == CCharDeathPhase::CoreEffectsCleaned) + { + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::BeforePhase); + tx.phase = CCharDeathPhase::LootDispatched; + tx.retryCount = 0; + tx.dispatchedCallbacks |= CCharDeathCallback::CreateLoot; + if (!tx.legacyRecovery && IsTrigUsed(TRIGGER_CREATELOOT)) + ReadScriptReducedTrig(Char_GetDef(), CTRIG_CreateLoot, false); + CCharDeathFaultInjector::Check(CCharDeathPhase::CoreEffectsCleaned, CCharDeathFaultBoundary::AfterSideEffect); + } + + // Create the corpse item + CItemCorpse *pCorpse = dynamic_cast(CUID(tx.corpseUid).ItemFind()); + if (tx.phase == CCharDeathPhase::LootDispatched) + { + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::BeforePhase); + if (!pCorpse && (tx.retryCount > 0 || tx.legacyRecovery)) + pCorpse = FindMyCorpse(); + if (!pCorpse) + pCorpse = MakeCorpse(tx.frontFall); + if (pCorpse) + tx.corpseUid = pCorpse->GetUID().GetObjUID(); + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::AfterSideEffect); + tx.phase = CCharDeathPhase::CorpseEnsured; + tx.retryCount = 0; + } + if (tx.phase == CCharDeathPhase::CorpseEnsured) + { + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::BeforePhase); + tx.phase = CCharDeathPhase::CorpseTriggerDispatched; + tx.retryCount = 0; + tx.dispatchedCallbacks |= CCharDeathCallback::DeathCorpse; + if (pCorpse) + { + if (!tx.legacyRecovery && IsTrigUsed(TRIGGER_DEATHCORPSE)) + { + CScriptTriggerArgsPtr pScriptArgs = CScriptParserBufs::GetCScriptTriggerArgsPtr(); + pScriptArgs->Init(pCorpse); + OnTrigger(CTRIG_DeathCorpse, pScriptArgs, this); + } + } + CCharDeathFaultInjector::Check(CCharDeathPhase::CorpseEnsured, CCharDeathFaultBoundary::AfterSideEffect); + } /* else { // TODO: add a error msg? } */ - m_lastAttackers.clear(); // clear list of attackers - - // Play death animation (fall on ground) - UpdateCanSee(new PacketDeath(this, pCorpse, fFrontFall), m_pClient); - - if ( m_pNPC ) - { - if ( m_pNPC->m_bonded ) - { - m_CanMask |= CAN_C_GHOST; - UpdateMode(true, nullptr); - return DeathRequestResult::Success; - } - - if ( pCorpse ) - pCorpse->m_uidLink.InitUID(); + if (tx.phase == CCharDeathPhase::CorpseTriggerDispatched) + { + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::BeforePhase); + tx.phase = CCharDeathPhase::WorldNotified; + tx.retryCount = 0; + tx.dispatchedCallbacks |= CCharDeathCallback::WorldNotify; + m_lastAttackers.clear(); // clear list of attackers + + // Play death animation (fall on ground) + if (!tx.legacyRecovery) + UpdateCanSee(new PacketDeath(this, pCorpse, tx.frontFall), m_pClient); + CCharDeathFaultInjector::Check(CCharDeathPhase::CorpseTriggerDispatched, CCharDeathFaultBoundary::AfterSideEffect); + } - NPC_PetClearOwners(); - return DeathRequestResult::SuccessAndDelete; // delete the NPC - } + if (tx.phase == CCharDeathPhase::WorldNotified) + { + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::BeforePhase); + if (m_pNPC) + { + if (m_pNPC->m_bonded) + { + m_CanMask |= CAN_C_GHOST; + UpdateMode(true, nullptr); + } + else + { + if (pCorpse) + pCorpse->m_uidLink.InitUID(); + NPC_PetClearOwners(); + } + } - if ( m_pPlayer ) - { - llong iDelta = m_exp / 10; - ChangeExperience(- maximum(1, iDelta), pKiller); - if ( !(m_TagDefs.GetKeyNum("DEATHFLAGS") & DEATH_NOFAMECHANGE) ) - Noto_Fame( -GetFame()/10 ); - - lpctstr pszGhostName = nullptr; - const CCharBase *pCharDefPrev = CCharBase::FindCharBase( _iPrev_id ); - const bool fFemale = pCharDefPrev && pCharDefPrev->IsFemale(); - switch ( _iPrev_id ) - { - case CREID_GARGMAN: - case CREID_GARGWOMAN: - pszGhostName = ( fFemale ? "c_garg_ghost_woman" : "c_garg_ghost_man" ); - break; - case CREID_ELFMAN: - case CREID_ELFWOMAN: - pszGhostName = ( fFemale ? "c_elf_ghost_woman" : "c_elf_ghost_man" ); - break; - default: - pszGhostName = ( fFemale ? "c_ghost_woman" : "c_ghost_man" ); - break; - } - ASSERT(pszGhostName != nullptr); + if (m_pPlayer) + { + if (tx.DispatchOnce(CCharDeathCallback::Penalties)) + { + const llong iDelta = m_exp / 10; + ChangeExperience(-maximum(1, iDelta), pKiller); + if (!(m_TagDefs.GetKeyNum("DEATHFLAGS") & DEATH_NOFAMECHANGE)) + Noto_Fame(-GetFame() / 10); + ++m_pPlayer->m_wDeaths; + } - if ( !IsStatFlag(STATF_WAR) ) - StatFlag_Set(STATF_INSUBSTANTIAL); // manifest war mode for ghosts + lpctstr pszGhostName = nullptr; + const CCharBase *pCharDefPrev = CCharBase::FindCharBase(_iPrev_id); + const bool fFemale = pCharDefPrev && pCharDefPrev->IsFemale(); + switch (_iPrev_id) + { + case CREID_GARGMAN: + case CREID_GARGWOMAN: + pszGhostName = (fFemale ? "c_garg_ghost_woman" : "c_garg_ghost_man"); + break; + case CREID_ELFMAN: + case CREID_ELFWOMAN: + pszGhostName = (fFemale ? "c_elf_ghost_woman" : "c_elf_ghost_man"); + break; + default: + pszGhostName = (fFemale ? "c_ghost_woman" : "c_ghost_man"); + break; + } + ASSERT(pszGhostName != nullptr); - ++m_pPlayer->m_wDeaths; + if (!IsStatFlag(STATF_WAR)) + StatFlag_Set(STATF_INSUBSTANTIAL); // manifest war mode for ghosts - SetHue( HUE_DEFAULT ); // get all pale - SetID( (CREID_TYPE)(g_Cfg.ResourceGetIndexType( RES_CHARDEF, pszGhostName )) ); - LayerAdd( CItem::CreateScript( ITEMID_DEATHSHROUD, this ) ); + SetHue(HUE_DEFAULT); // get all pale + SetID((CREID_TYPE)(g_Cfg.ResourceGetIndexType(RES_CHARDEF, pszGhostName))); + if (!ContentFind(CResourceID(RES_ITEMDEF, ITEMID_DEATHSHROUD))) + LayerAdd(CItem::CreateScript(ITEMID_DEATHSHROUD, this)); + } + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::AfterSideEffect); + tx.phase = CCharDeathPhase::EntityFinalized; + tx.retryCount = 0; + } - CClient * pClient = GetClientActive(); - if ( pClient ) - { + if (tx.phase == CCharDeathPhase::EntityFinalized) + { + CCharDeathFaultInjector::Check(tx.phase, CCharDeathFaultBoundary::BeforePhase); + tx.phase = CCharDeathPhase::Completed; + CClient *pClient = GetClientActive(); + if (m_pPlayer && pClient && !tx.legacyRecovery) + { if (g_Cfg.m_iPacketDeathAnimation) - { - // OSI uses PacketDeathMenu to update client screen on death. - // If the user disable this packet, it must be updated using addPlayerUpdate() - - // Display death animation to client ("You are dead") new PacketDeathMenu(pClient, PacketDeathMenu::Dead); - } else - { pClient->addPlayerUpdate(); - } pClient->addPlayerWarMode(); pClient->addSeason(SEASON_Desolate); - pClient->addMapWaypoint(pCorpse, MAPWAYPOINT_Corpse); // add corpse map waypoint on enhanced clients - pClient->addTargetCancel(); // cancel target if player death - - CItem *pPack = LayerFind(LAYER_PACK); - if ( pPack ) + pClient->addMapWaypoint(pCorpse, MAPWAYPOINT_Corpse); + pClient->addTargetCancel(); + if (CItem *pPack = LayerFind(LAYER_PACK)) { pPack->RemoveFromView(); pPack->Update(); } - - // Remove the characters which I can't see as dead from the screen if (g_Cfg.m_fDeadCannotSeeLiving) { - auto AreaChars = CWorldSearchHolder::GetInstance(GetTopPoint(), g_Cfg.m_iMapViewSizeMax); - AreaChars->SetSearchSquare(true); - for (;;) + auto areaChars = CWorldSearchHolder::GetInstance(GetTopPoint(), g_Cfg.m_iMapViewSizeMax); + areaChars->SetSearchSquare(true); + for (CChar *pChar = areaChars->GetChar(); pChar; pChar = areaChars->GetChar()) { - CChar *pChar = AreaChars->GetChar(); - if (!pChar) - break; if (!CanSeeAsDead(pChar)) pClient->addObjectRemove(pChar); } } + } + CCharDeathFaultInjector::Check(CCharDeathPhase::EntityFinalized, CCharDeathFaultBoundary::AfterSideEffect); + g_Log.EventDebug("DeathTxn complete uid=0%" PRIx32 " tx=%" PRIu32 ".\n", GetUID().GetObjUID(), tx.transactionId); + } + return (m_pNPC && !m_pNPC->m_bonded) ? DeathRequestResult::SuccessAndDelete : DeathRequestResult::Success; - } - } - return DeathRequestResult::Success; + EXC_CATCH; + const CCharDeathPhase failedPhase = m_deathTransaction.phase; + const bool fEmergency = m_deathTransaction.RegisterFailure(m_pNPC && !m_pNPC->m_bonded); + static constexpr int64 sm_iRetryDelay[] = { MSECS_PER_TENTH, 5 * MSECS_PER_TENTH, MSECS_PER_SEC, 5 * MSECS_PER_SEC, 30 * MSECS_PER_SEC }; + const size_t iDelay = minimum((size_t)(m_deathTransaction.retryCount - 1), ARRAY_COUNT(sm_iRetryDelay) - 1); + m_deathTransaction.nextRetryTime = CWorldGameTime::GetCurrentTime().GetTimeRaw() + sm_iRetryDelay[iDelay]; + _iTimeNextRegen = m_deathTransaction.nextRetryTime; + if (m_deathTransaction.retryCount == 1 || m_deathTransaction.retryCount == 2 || m_deathTransaction.retryCount == 4 || m_deathTransaction.retryCount == 8) + { + const CPointMap pt = GetTopPoint(); + g_Log.EventError("DeathTxn phase-failed uid=0%" PRIx32 " tx=%" PRIu32 " phase=%s attempt=%u hits=%u flags=0%" PRIx64 + " p=%s sleeping=%d sectorSleeping=%d corpse=0%" PRIx32 ".\n", + GetUID().GetObjUID(), m_deathTransaction.transactionId, CCharDeathPhaseName(failedPhase), m_deathTransaction.retryCount, Stat_GetVal(STAT_STR), + _uiStatFlag, pt.WriteUsed(), _IsSleeping(), GetTopSector()->IsSleeping(), m_deathTransaction.corpseUid); + } + if (fEmergency) + { + g_Log.EventError("DeathTxn emergency-finalization uid=0%" PRIx32 " tx=%" PRIu32 " phase=%s.\n", GetUID().GetObjUID(), m_deathTransaction.transactionId, + CCharDeathPhaseName(failedPhase)); + } + else if ((!m_pNPC || m_pNPC->m_bonded) && m_deathTransaction.retryCount > 8 && ((m_deathTransaction.retryCount - 8) % 20 == 0)) + { + g_Log.EventError("DeathTxn persistent-failure uid=0%" PRIx32 " tx=%" PRIu32 " phase=%s attempt=%u; player/bonded entity retained.\n", + GetUID().GetObjUID(), m_deathTransaction.transactionId, CCharDeathPhaseName(failedPhase), m_deathTransaction.retryCount); + } + return DeathRequestResult::InProgress; +} + +CChar::DeathRequestResult CChar::ContinueDeath() +{ + return Death(); } // Check if we are held in place. @@ -5919,15 +6053,21 @@ bool CChar::_CanTick(bool fParentGoingToSleep) const //ADDTOCALLSTACK_DEBUG("CChar::_CanTick"); EXC_TRY("Able to tick?"); + // An accepted death transaction is a lifecycle invariant, not ambient AI. + // It must continue even when the sector is going to sleep. + if (HasActiveDeathTransaction() || + CCharShouldStartLegacyDeathRecovery(IsStatFlag(STATF_DEAD), m_pNPC != nullptr, m_pNPC && m_pNPC->m_bonded, HasActiveDeathTransaction())) + return true; + if (IsDisconnected()) - { + { if (!IsTickableEvenIfDisconnected()) return false; - } + } return CObjBase::_CanTick(fParentGoingToSleep); - EXC_CATCH; + EXC_CATCH; return false; } @@ -6099,10 +6239,12 @@ bool CChar::OnTickPeriodic() * it should also be called before stat regen, since death happen in the last tick and regen belongs to the new tick * and it makes no sense to regen some hits after death. */ - if (!IsStatFlag(STATF_DEAD) && (Stat_GetVal(STAT_STR) <= 0)) + const bool fLegacyDeathRecovery = + CCharShouldStartLegacyDeathRecovery(IsStatFlag(STATF_DEAD), m_pNPC != nullptr, m_pNPC && m_pNPC->m_bonded, HasActiveDeathTransaction()); + if (HasActiveDeathTransaction() || fLegacyDeathRecovery || (!IsStatFlag(STATF_DEAD) && (Stat_GetVal(STAT_STR) <= 0))) { EXC_SET_BLOCK("death?"); - const DeathRequestResult deathRes = Death(); + const DeathRequestResult deathRes = HasActiveDeathTransaction() ? ContinueDeath() : Death(); if ((deathRes != DeathRequestResult::Aborted) && (deathRes != DeathRequestResult::AbortedNoLog)) { if (deathRes == DeathRequestResult::AlreadyDead) @@ -6113,12 +6255,12 @@ bool CChar::OnTickPeriodic() } else { -//#ifdef _DEBUG + //#ifdef _DEBUG if (deathRes != DeathRequestResult::AbortedNoLog) { g_Log.EventEvent("Aborted char '%s' (0x%" PRIx32 " ) death.\n", GetName(), GetUID().GetObjUID()); } -//#endif + //#endif ; // Then, fall through. } } diff --git a/src/game/chars/CCharDeathState.h b/src/game/chars/CCharDeathState.h new file mode 100644 index 000000000..cb533dcdc --- /dev/null +++ b/src/game/chars/CCharDeathState.h @@ -0,0 +1,192 @@ +/** + * @file CCharDeathState.h + * Persistent state for the resumable character death workflow. + */ +#ifndef _INC_CCHARDEATHSTATE_H +#define _INC_CCHARDEATHSTATE_H + +#include +#include +#ifdef UNIT_TESTING +# include +#endif + +enum class CCharDeathPhase : std::uint8_t +{ + None = 0, + Accepted, + InteractionsDetached, + AttributionDispatched, + DeadCommitted, + CoreEffectsCleaned, + LootDispatched, + CorpseEnsured, + CorpseTriggerDispatched, + WorldNotified, + EntityFinalized, + Completed, + EmergencyFinalization +}; + +[[nodiscard]] +constexpr const char *CCharDeathPhaseName(const CCharDeathPhase phase) noexcept +{ + switch (phase) + { + case CCharDeathPhase::None: + return "None"; + case CCharDeathPhase::Accepted: + return "Accepted"; + case CCharDeathPhase::InteractionsDetached: + return "InteractionsDetached"; + case CCharDeathPhase::AttributionDispatched: + return "AttributionDispatched"; + case CCharDeathPhase::DeadCommitted: + return "DeadCommitted"; + case CCharDeathPhase::CoreEffectsCleaned: + return "CoreEffectsCleaned"; + case CCharDeathPhase::LootDispatched: + return "LootDispatched"; + case CCharDeathPhase::CorpseEnsured: + return "CorpseEnsured"; + case CCharDeathPhase::CorpseTriggerDispatched: + return "CorpseTriggerDispatched"; + case CCharDeathPhase::WorldNotified: + return "WorldNotified"; + case CCharDeathPhase::EntityFinalized: + return "EntityFinalized"; + case CCharDeathPhase::Completed: + return "Completed"; + case CCharDeathPhase::EmergencyFinalization: + return "EmergencyFinalization"; + } + return "Invalid"; +} + +enum class CCharDeathFaultBoundary : std::uint8_t +{ + BeforePhase, + AfterSideEffect +}; + +[[nodiscard]] +constexpr bool CCharShouldStartLegacyDeathRecovery(const bool dead, const bool npc, const bool bonded, const bool transactionActive) noexcept +{ + return dead && npc && !bonded && !transactionActive; +} + +class CCharDeathFaultInjector { +public: +#ifdef UNIT_TESTING + static inline CCharDeathPhase sm_phase = CCharDeathPhase::None; + static inline CCharDeathFaultBoundary sm_boundary = CCharDeathFaultBoundary::BeforePhase; + static inline unsigned sm_remaining = 0; + + static void Arm(CCharDeathPhase phase, CCharDeathFaultBoundary boundary, unsigned count = 1) noexcept + { + sm_phase = phase; + sm_boundary = boundary; + sm_remaining = count; + } + + static void Reset() noexcept + { + sm_remaining = 0; + } + + static void Check(CCharDeathPhase phase, CCharDeathFaultBoundary boundary) + { + if (sm_remaining && sm_phase == phase && sm_boundary == boundary) + { + --sm_remaining; + throw std::runtime_error("Injected death transaction failure"); + } + } +#else + static constexpr void Check(CCharDeathPhase, CCharDeathFaultBoundary) noexcept + {} +#endif +}; + +struct CCharDeathTransaction +{ + static constexpr std::uint8_t kSaveVersion = 1; + static constexpr std::uint16_t kMaxRetries = 8; + + CCharDeathPhase phase = CCharDeathPhase::None; + std::uint32_t transactionId = 0; + std::uint32_t killerUid = 0; + std::uint32_t corpseUid = 0; + std::uint32_t dispatchedCallbacks = 0; + std::uint16_t retryCount = 0; + std::int64_t nextRetryTime = 0; + bool frontFall = false; + bool legacyRecovery = false; + bool executing = false; // transient; never serialized + + [[nodiscard]] + constexpr bool IsActive() const noexcept + { + return phase != CCharDeathPhase::None && phase != CCharDeathPhase::Completed; + } + + [[nodiscard]] + constexpr bool DispatchOnce(const std::uint32_t mask) noexcept + { + if ((dispatchedCallbacks & mask) != 0) + return false; + dispatchedCallbacks |= mask; + return true; + } + + [[nodiscard]] + constexpr bool RegisterFailure(const bool fCanEmergencyFinalize) noexcept + { + if (retryCount < std::numeric_limits::max()) + ++retryCount; + if (fCanEmergencyFinalize && retryCount >= kMaxRetries) + { + phase = CCharDeathPhase::EmergencyFinalization; + nextRetryTime = 0; + return true; + } + return false; + } + + constexpr void Reset() noexcept + { + *this = {}; + } +}; + +class CCharDeathExecutionGuard { +public: + explicit CCharDeathExecutionGuard(CCharDeathTransaction &transaction) noexcept : + m_transaction(transaction) + { + m_transaction.executing = true; + } + + ~CCharDeathExecutionGuard() + { + m_transaction.executing = false; + } + + CCharDeathExecutionGuard(const CCharDeathExecutionGuard &) = delete; + CCharDeathExecutionGuard &operator=(const CCharDeathExecutionGuard &) = delete; + +private: + CCharDeathTransaction &m_transaction; +}; + +namespace CCharDeathCallback +{ +static constexpr std::uint32_t Death = 1u << 0; +static constexpr std::uint32_t Attribution = 1u << 1; +static constexpr std::uint32_t CreateLoot = 1u << 2; +static constexpr std::uint32_t DeathCorpse = 1u << 3; +static constexpr std::uint32_t Penalties = 1u << 4; +static constexpr std::uint32_t WorldNotify = 1u << 5; +} // namespace CCharDeathCallback + +#endif diff --git a/src/game/chars/CCharSpell.cpp b/src/game/chars/CCharSpell.cpp index ac82d8dd1..614ba7db0 100644 --- a/src/game/chars/CCharSpell.cpp +++ b/src/game/chars/CCharSpell.cpp @@ -470,6 +470,7 @@ bool CChar::Spell_Resurrection(CItemCorpse * pCorpse, CChar * pCharSrc, bool fNo SetID(_iPrev_id); SetHue(_wPrev_Hue); + m_deathTransaction.Reset(); StatFlag_Clear(STATF_DEAD|STATF_INSUBSTANTIAL); Stat_SetVal(STAT_STR, maximum(uiHits, 1)); diff --git a/src/tables/CChar_props.tbl b/src/tables/CChar_props.tbl index 17e05a718..fb6b9cfc4 100644 --- a/src/tables/CChar_props.tbl +++ b/src/tables/CChar_props.tbl @@ -29,6 +29,7 @@ ADD(CANMOVE, "CANMOVE") ADD(CREATE, "CREATE") ADD(CURFOLLOWER, "CURFOLLOWER") ADD(DAMADJUSTED, "DAMADJUSTED") +ADD(DEATHTXN, "DEATHTXN") ADD(DEX, "DEX") ADD(DIR, "DIR") ADD(DISMOUNT, "DISMOUNT") diff --git a/tests/CMakeSources.cmake b/tests/CMakeSources.cmake index c82e4cfac..6a725a53f 100644 --- a/tests/CMakeSources.cmake +++ b/tests/CMakeSources.cmake @@ -1,4 +1,5 @@ set(SPHERE_TESTS_SOURCES + tests/src/t_CCharDeathState.cpp tests/src/t_num_parsing.cpp tests/src/t_CPointBase.cpp tests/src/t_CUOClientVersion.cpp diff --git a/tests/src/t_CCharDeathState.cpp b/tests/src/t_CCharDeathState.cpp new file mode 100644 index 000000000..0894c85fd --- /dev/null +++ b/tests/src/t_CCharDeathState.cpp @@ -0,0 +1,98 @@ +#include + +#include "../../src/game/chars/CCharDeathState.h" + +TEST_CASE("death transaction remains recoverable until completion") +{ + CCharDeathTransaction tx; + CHECK_FALSE(tx.IsActive()); + + tx.phase = CCharDeathPhase::DeadCommitted; + CHECK(tx.IsActive()); + + tx.phase = CCharDeathPhase::Completed; + CHECK_FALSE(tx.IsActive()); +} + +TEST_CASE("irreversible death callbacks are dispatched at most once") +{ + CCharDeathTransaction tx; + CHECK(tx.DispatchOnce(CCharDeathCallback::DeathCorpse)); + CHECK_FALSE(tx.DispatchOnce(CCharDeathCallback::DeathCorpse)); + CHECK(tx.DispatchOnce(CCharDeathCallback::Penalties)); +} + +TEST_CASE("repeated engine failures eventually select NPC emergency cleanup") +{ + CCharDeathTransaction tx; + tx.phase = CCharDeathPhase::CoreEffectsCleaned; + + for (std::uint16_t attempt = 1; attempt < CCharDeathTransaction::kMaxRetries; ++attempt) + { + CHECK_FALSE(tx.RegisterFailure(true)); + CHECK(tx.phase == CCharDeathPhase::CoreEffectsCleaned); + } + + CHECK(tx.RegisterFailure(true)); + CHECK(tx.phase == CCharDeathPhase::EmergencyFinalization); +} + +TEST_CASE("players and bonded pets are never emergency-deleted") +{ + CCharDeathTransaction tx; + tx.phase = CCharDeathPhase::EntityFinalized; + for (unsigned attempt = 0; attempt < CCharDeathTransaction::kMaxRetries + 2; ++attempt) + CHECK_FALSE(tx.RegisterFailure(false)); + CHECK(tx.phase == CCharDeathPhase::EntityFinalized); +} + +TEST_CASE("death fault injector targets an exact phase boundary") +{ + CCharDeathFaultInjector::Arm(CCharDeathPhase::DeadCommitted, CCharDeathFaultBoundary::AfterSideEffect); + CHECK_NOTHROW(CCharDeathFaultInjector::Check(CCharDeathPhase::DeadCommitted, CCharDeathFaultBoundary::BeforePhase)); + CHECK_THROWS_AS(CCharDeathFaultInjector::Check(CCharDeathPhase::DeadCommitted, CCharDeathFaultBoundary::AfterSideEffect), std::runtime_error); + CHECK_NOTHROW(CCharDeathFaultInjector::Check(CCharDeathPhase::DeadCommitted, CCharDeathFaultBoundary::AfterSideEffect)); + CCharDeathFaultInjector::Reset(); +} + +TEST_CASE("every resumable phase exposes before and after fault boundaries") +{ + constexpr CCharDeathPhase phases[] = { CCharDeathPhase::Accepted, CCharDeathPhase::InteractionsDetached, CCharDeathPhase::AttributionDispatched, + CCharDeathPhase::DeadCommitted, CCharDeathPhase::CoreEffectsCleaned, CCharDeathPhase::LootDispatched, CCharDeathPhase::CorpseEnsured, + CCharDeathPhase::CorpseTriggerDispatched, CCharDeathPhase::WorldNotified, CCharDeathPhase::EntityFinalized, CCharDeathPhase::EmergencyFinalization }; + + for (const CCharDeathPhase phase : phases) + { + CAPTURE(CCharDeathPhaseName(phase)); + CCharDeathFaultInjector::Arm(phase, CCharDeathFaultBoundary::BeforePhase); + CHECK_THROWS_AS(CCharDeathFaultInjector::Check(phase, CCharDeathFaultBoundary::BeforePhase), std::runtime_error); + + CCharDeathFaultInjector::Arm(phase, CCharDeathFaultBoundary::AfterSideEffect); + CHECK_THROWS_AS(CCharDeathFaultInjector::Check(phase, CCharDeathFaultBoundary::AfterSideEffect), std::runtime_error); + } + CCharDeathFaultInjector::Reset(); +} + +TEST_CASE("death execution guard releases the reentrancy lock") +{ + CCharDeathTransaction tx; + CHECK_FALSE(tx.executing); + { + CCharDeathExecutionGuard guard(tx); + CHECK(tx.executing); + } + CHECK_FALSE(tx.executing); +} + +TEST_CASE("legacy zombie NPC enters recovery while intentional ghosts do not") +{ + // Old OnTickPeriodic skipped every STATF_DEAD character, which left this + // exact non-bonded NPC state permanently in the world. + constexpr bool legacyWouldRemainStuck = true; + CHECK(legacyWouldRemainStuck); + CHECK(CCharShouldStartLegacyDeathRecovery(true, true, false, false)); + + CHECK_FALSE(CCharShouldStartLegacyDeathRecovery(true, false, false, false)); // player ghost + CHECK_FALSE(CCharShouldStartLegacyDeathRecovery(true, true, true, false)); // bonded ghost + CHECK_FALSE(CCharShouldStartLegacyDeathRecovery(true, true, false, true)); // already recovering +}