【我?guī)旺椊切辀ug】明明是我先來(lái)的:buff遍歷邏輯引發(fā)的一系列bug

前言和簡(jiǎn)介
這個(gè)系列內(nèi)容大概是科學(xué)分析一些游戲bug或"bug"的原理并給出可能的解決方案,往期內(nèi)容歡迎查看本人專欄。
本期涉及的bug比較多,內(nèi)容比較雜,但這些內(nèi)容都是由一個(gè)根本原因?qū)е碌模篵uff的結(jié)束/回收問(wèn)題。這個(gè)看似不起眼的問(wèn)題在過(guò)去大半年內(nèi)造成了一堆bug,并且由于近大半年來(lái)(可能是新接手的)方舟程序員特別愛(ài)加各種buff事件,導(dǎo)致這個(gè)問(wèn)題有愈演愈烈的趨勢(shì),因此有必要對(duì)這個(gè)問(wèn)題引起重視。
本期部分內(nèi)容為合作完成,非常感謝@fux2和@朝夢(mèng)cfx兩位大佬的幫助(特別是動(dòng)態(tài)調(diào)試方面)。

緩存的列表
設(shè)想一個(gè)問(wèn)題:
現(xiàn)在你需要遍歷一個(gè)列表(動(dòng)態(tài)數(shù)組)a,對(duì)其中部分/每個(gè)元素執(zhí)行一定的操作,這些操作根據(jù)元素有所不同,且在遍歷前是未知的,其中可能包含修改a的操作,甚至可能包含類似的遍歷行為。這種情況下,該怎么做才能保證這個(gè)遍歷的可靠性?
這個(gè)問(wèn)題就是方舟buff系統(tǒng)所面臨的,也是這期所有內(nèi)容的起源。在戰(zhàn)斗中,各種不同的事件可以觸發(fā)buff在對(duì)應(yīng)事件上的行為。例如,傀影血色樂(lè)章的buff,設(shè)置在成功造成傷害后減少一層自身。在傀影成功造成傷害后的這個(gè)事件上,程序會(huì)遍歷傀影buff池中的所有buff,并依次執(zhí)行對(duì)應(yīng)的行為,而在遍歷至血色樂(lè)章buff時(shí),會(huì)執(zhí)行減少一層對(duì)應(yīng)buff的行為。
但這里存在之前所說(shuō)的問(wèn)題:有一些需要執(zhí)行的行為是創(chuàng)建/結(jié)束buff,這就必然涉及到對(duì)buff列表的修改。這種情況下,怎樣確保遍歷可以繼續(xù)穩(wěn)定進(jìn)行呢?
方舟中使用了一個(gè)非常簡(jiǎn)單且符合直覺(jué)的辦法:遍歷前把原列表(internalList)拷貝一份,遍歷的時(shí)候遍歷這個(gè)緩存的列表(cachedBuffer),在遍歷的過(guò)程中如果有需要執(zhí)行的操作可以直接對(duì)原列表進(jìn)行修改。這樣就避免了容器修改導(dǎo)致的遍歷失效的問(wèn)題。
但是,不知是出于什么考慮,這個(gè)緩存列表只被允許緩存一次,已緩存的情況下再次嘗試緩存列表,獲取的緩存列表會(huì)是之前緩存的列表,而非即時(shí)從原列表(internalList)拷貝的列表。
以上的邏輯在部分情況下可能會(huì)導(dǎo)致bug。一個(gè)例子是:42的自動(dòng)撤回和黑夜圖的視野擴(kuò)大同幀觸發(fā)時(shí),會(huì)導(dǎo)致視野常駐。這個(gè)bug的表現(xiàn)可見(jiàn)BV1ai4y1y7Kf。
這個(gè)bug的原因概括起來(lái)比較簡(jiǎn)單:大致是由于緩存列表更新不及時(shí),導(dǎo)致在觸發(fā)撤回嘗試結(jié)束并清空buff時(shí),新創(chuàng)建的視野擴(kuò)大buff沒(méi)有被正常結(jié)束。甚至在buff列表清空后,這個(gè)buff依然不會(huì)被結(jié)束,而是處在一種類似被弄丟的狀態(tài)。因此視野也會(huì)一直維持著點(diǎn)亮狀態(tài)。

比較詳細(xì)的解釋也可以參考@朝夢(mèng)cfx的這個(gè)動(dòng)態(tài)。這里就不再多贅述了,畢竟這不是重點(diǎn)。
那么,是不是允許多層的緩存列表,讓緩存能夠比較及時(shí)的更新,就能解決這個(gè)問(wèn)題呢?
答案是,能解決,但也只能解決這個(gè)問(wèn)題,還有其它的問(wèn)題在后面等著。

嵌套的邏輯
不知道各位有沒(méi)有聽(tīng)說(shuō)過(guò)當(dāng)初貝娜+古堡的子嗣的bug,貝娜本體替身切換可以直接疊一層加防buff,最后可以達(dá)到上萬(wàn)防御。具體的表現(xiàn)可見(jiàn)視頻BV1E44y157xZ。但稍微對(duì)機(jī)制有所了解的朋友應(yīng)該知道,傀儡師切替身/切本體的時(shí)候會(huì)清空身上除白名單外所有buff。那么,這個(gè)加防buff是怎樣逃過(guò)清空buff的行為疊上去的呢?
問(wèn)題版本的古堡的子嗣,它的行為大致是這樣的:我方單位出生時(shí)(傀儡師切替身/本體后在這里也視作出生)創(chuàng)建一層持續(xù)100s的計(jì)時(shí)buff;計(jì)時(shí)buff會(huì)在結(jié)束時(shí)(onBuffFinish)創(chuàng)建一層永久的加防buff。
看上去似乎沒(méi)有問(wèn)題。那么我們可以推一下,傀儡師清空自身buff時(shí),發(fā)生了什么。

和上面視野bug原因類似,都是緩存列表導(dǎo)致的結(jié)束/清空buff不徹底。但這次,由于buff結(jié)束事件的存在,即時(shí)緩存的情況下仍然觸發(fā)了類似的bug。而程序員最后選擇的修復(fù)方案并不是修改清空buff的邏輯(比如獲取原列表逐個(gè)結(jié)束移除),而是修改了古堡的子嗣的實(shí)現(xiàn),繞開(kāi)了buff結(jié)束事件創(chuàng)建加防buff。隱患仍然存在。
可能也是認(rèn)為buff的結(jié)束與移除在嵌套邏輯中非常難以把控,除去緩存列表外,程序員還做出了另一項(xiàng)優(yōu)化:除清空buff池外,其它任何時(shí)候結(jié)束buff,都只是將buff標(biāo)記為已結(jié)束并令其失效,正式的結(jié)束/移除/回收需要在各事件末尾才會(huì)統(tǒng)一檢查/執(zhí)行。
然而,這在某些地方帶來(lái)了大麻煩。

延遲的結(jié)束
統(tǒng)一結(jié)束這個(gè)措施,造成的最直接后果就是:在部分情況下,buff的結(jié)束事件存在延遲,往往是延遲到下一個(gè)事件結(jié)束,這在部分情況下,尤其是多個(gè)單位參與時(shí),造成的影響是非常巨大的。
初版令的3技能召喚物在部分情況下可以攻擊自己,這就是一個(gè)非常典型的,由延遲結(jié)束造成的bug。關(guān)于這個(gè)bug詳情和觸發(fā)方式可以見(jiàn)視頻BV1Q44y1p79s。初版的實(shí)現(xiàn)中,召喚物的模式切換是通過(guò)來(lái)自令本體的光環(huán)實(shí)現(xiàn)的,光環(huán)buff的buff結(jié)束事件是讓召喚物切換回原模式。但是,由于延遲結(jié)束的邏輯,這個(gè)buff并不是在光環(huán)被結(jié)束時(shí)立刻觸發(fā)buff結(jié)束事件,而是延遲到了下一個(gè)事件結(jié)束。一般情況下,這個(gè)事件會(huì)是下一幀的buff的tick,這種情況下切換模式不會(huì)造成任何問(wèn)題。但是如果下一幀剛好是新一輪普攻的開(kāi)始,那么這個(gè)事件就會(huì)被提前到新一輪普攻的OnBeforeAttack。由于程序員在設(shè)計(jì)character類時(shí)可能根本沒(méi)有考慮過(guò)在OnBeforeAttack切換模式的問(wèn)題,在這個(gè)事件上切換攻擊模式,會(huì)導(dǎo)致傳入的攻擊目標(biāo)在一系列奇妙的化學(xué)反應(yīng)下變成0,然后又在一系列奇妙的化學(xué)反應(yīng)變成攻擊自己。
不過(guò)很可惜,程序員依然選擇了治標(biāo)的方案,直接修改了令3技能和召喚物的實(shí)現(xiàn)繞過(guò)了這個(gè)問(wèn)題。但延遲結(jié)束造成的問(wèn)題可遠(yuǎn)不止令一個(gè)。
在將進(jìn)酒版本更新后,有人發(fā)現(xiàn):在部分情況下,火球術(shù)士的火球在爆炸后可能使部分單位停止攻擊。這個(gè)bug非常奇怪的一點(diǎn)在于,火球術(shù)士早在9月就已實(shí)裝,且自身的實(shí)現(xiàn)沒(méi)有任何更改。但在將進(jìn)酒版本更新前,并沒(méi)有任何與這個(gè)bug相關(guān)的記錄。
那么原因在哪呢?翻看火球的實(shí)現(xiàn),可以發(fā)現(xiàn),火球擁有一個(gè)限制生命時(shí)長(zhǎng)的buff,這個(gè)buff持續(xù)一定時(shí)間,并且會(huì)在結(jié)束時(shí)使火球退場(chǎng)。而火球的爆炸,會(huì)通過(guò)爆炸ability中的action提前結(jié)束這個(gè)buff來(lái)讓火球退場(chǎng)。一般情況下,這個(gè)結(jié)束事件會(huì)被延遲到火球自身下一幀的buff tick,這個(gè)時(shí)候撤回不會(huì)造成任何bug,但是異常情況下呢?
回看將進(jìn)酒活動(dòng),其中實(shí)裝了老鯉,而老鯉的1天賦讓這個(gè)游戲加了一個(gè)名為OnOwnerBlockeeChanged的buff事件,大概是在阻擋/被阻擋狀態(tài)發(fā)生變化時(shí)觸發(fā)。而火球,不知道為什么是個(gè)可阻擋敵人,而阻擋的判定,由于火球通常是后于阻擋單位生成,也位于火球的buff tick前。


下圖為上圖中RegisterBlocker函數(shù)的部分展開(kāi)
從圖中不難發(fā)現(xiàn),如果火球在OnOwnerBlockeeChanged的過(guò)程中退場(chǎng),在退場(chǎng)過(guò)程完成后仍然會(huì)被添加至character的阻擋列表中。而之后這個(gè)火球會(huì)在未被清除阻擋的情況下變?yōu)閚ull,相當(dāng)于角色阻擋了一個(gè)不存在的單位。根據(jù)和上面令bug中相似的原理,攻擊目標(biāo)被設(shè)置為0后一定條件下會(huì)導(dǎo)致打自己的bug,具體表現(xiàn)可見(jiàn)視頻BV1834y187Vj。這就是火球bug的原理。而將進(jìn)酒之前沒(méi)有這個(gè)bug,是因?yàn)閷⑦M(jìn)酒之前根本不存在這個(gè)buff事件。
除此之外還有諸多類似的問(wèn)題,比如老鯉2技能奇葩的撤回傷害判定,流明2天賦可同時(shí)奶2,流明+老鯉造成的buff回收錯(cuò)亂,以上這些均和buff延遲回收有關(guān)。具體原理這里就不細(xì)說(shuō)了也沒(méi)什么意義。在buff事件越來(lái)越多的現(xiàn)在,buff的延遲結(jié)束也有著更大的可能造成未知的影響。

修復(fù)方案
雖然看起來(lái)挺災(zāi)難的,但仔細(xì)分析了下問(wèn)題主要集中在幾點(diǎn)上,修修還能用,應(yīng)該還用不著重構(gòu)。以下是個(gè)人的修改建議:
1. buff池的清空方法需要修改。目前的方法是遍歷緩存列表來(lái)結(jié)束/清空,導(dǎo)致部分情況下結(jié)束不干凈。個(gè)人認(rèn)為,需要使用非enumerator的方式,例如每次對(duì)內(nèi)部列表的最后一個(gè)元素進(jìn)行操作,來(lái)結(jié)束/清空buff。這樣可以保證清的干凈。但是需要注意一點(diǎn),這種情況下,左腳踩右腳上天的嵌套buff,例如流明2天賦,buff A在結(jié)束時(shí)會(huì)創(chuàng)建buff B,buff B在結(jié)束時(shí)創(chuàng)建buff A,是堅(jiān)決不能被容忍的。舊邏輯下,這樣的buff僅僅是清不干凈,如果沒(méi)有屬性變化不會(huì)造成問(wèn)題;但在新邏輯下,這樣的嵌套buff在清空buff池時(shí)是必然會(huì)造成無(wú)限循環(huán)的(除非能提前判斷這是個(gè)無(wú)限循環(huán)然后跳出去,但這太麻煩了)。
2. buff結(jié)束邏輯需要修改。我暫時(shí)沒(méi)有什么更好的方法,只有一條:在目標(biāo)buff池的enumerator外,也就是e_counter == 0,結(jié)束buff時(shí),完全可以直接結(jié)束+移除,而不僅僅是標(biāo)記為結(jié)束。改掉這一條上面絕大部分與延遲結(jié)束相關(guān)的bug應(yīng)該都能修好。
3. 未來(lái)的開(kāi)發(fā)的中盡量少使用OnFinish這個(gè)buff事件,這個(gè)直接和buff移除相關(guān)聯(lián)的事件真的容易造成問(wèn)題。也許用OnDisable和OnTrigger替代會(huì)好一些。
4. 也許可以嘗試修改掉OnFinish事件執(zhí)行行為的位置,讓它在buff被首次標(biāo)記為結(jié)束時(shí)立刻觸發(fā),而不是等到統(tǒng)一移除/回收時(shí)。
最后,關(guān)于重構(gòu),雖然不推薦,但是我還是試著寫了個(gè)究極簡(jiǎn)化版的基于鏈表的buff池,這樣可以不需要使用緩存列表,讓對(duì)buff池的遍歷完全實(shí)時(shí)更新。buff池本身并不需要隨機(jī)訪問(wèn),用鏈表速度應(yīng)該也能快些。上面的問(wèn)題應(yīng)該都能解決,但是很可能整出一堆新bug就是。
https://wtools.io/paste-code/bCWa
當(dāng)然,最穩(wěn)的方法還是將對(duì)buff池進(jìn)行修改的操作弄一個(gè)隊(duì)列,然后統(tǒng)一放到最外層遍歷循環(huán)之外執(zhí)行。但是這樣的話可能很多原有的buff邏輯都要大改。