夜花櫻TINYYANA
今晚的顏色櫻粉

夜、花、櫻。名字裡的三個字就是上面那片天空,拉一下會跟著換。

今晚的音樂

月夜蝶~君ヲ想フ~,來自H/MIX GALLERY。 音量在這裡調,右上角能暫停。

← 回筆記
設計決策

反 X-Ray 的第三條路:礦根本不用藏

把 Lycohinya 在用的反 X-Ray 插件整理成獨立插件放出來了,叫 Kyokalith。這篇把機制攤開來講,寫給會想自己做一個的人。

為什麼不用現成的

反 X-Ray 就兩派做法。

封包混淆:攔截 chunk packet,把還沒露出來的礦換成假方塊,等玩家真的挖到再補真資料,Paper 甚至內建這功能(anti-xray 的 engine-mode)。問題有兩個。freecam 跟種子地圖不吃這套——前者把攝影機開進牆裡,後者根本不碰你的伺服器,拿世界種子直接算出原版礦生成在哪。然後每個玩家的每個 chunk packet 都要處理,人多的時候這是常駐 CPU 成本。

清礦重生:用 datapack 把 configured_feature 裡的礦全部拔掉,礦的位置由插件自己決定。透視這下真的看不到了,世界檔裡沒有礦。代價是「礦要重新長出來」變成你的責任,而那意味著掃區塊。我第一版(v0.3)就是這樣做的:23 個 configured_feature JSON 拔礦,區塊載入時掃描重生。實測 121 個 forceload 區塊,TPS 18.9。把整套掃描連 datapack 一起刪掉,回 20.1。

那次之後這個 repo 的文件裡多了一條紅線:任何形式的區塊掃描都不准,包括「順便掃一下」。

誘餌模型

先致敬:「挖開時才計算」這條路不是我開的。好幾年前 LogoCat 為廢土伺服器寫的 Ore Replacer 礦物代換 就是這個做法——透視看到的礦挖開變石頭、不代換正常可見的方塊、一般玩家完全無感,連「玩家蓋方塊會害礦物消失」都做了保護機制,而且當年就開源。Kyokalith 的做法有參考他,算是把這條路接著往下走:把挖開時的機率計算換成吃 salt 的確定性函數,讓種子地圖也失效、讓重算安全,再把覆蓋保護擴成事件層的 dirty 規則。

換個角度:透視之所以有用,是因為它看到的礦是真的。那就讓它看到的礦不保證是真的——而且不用自己造假資料,原版生成的礦就是現成的假資料。

規則長這樣:

  • 世界生成完全不動。生成時就露在外面的礦(洞穴壁、峽谷面)是真的,永遠不會被碰。
  • 完全被實心方塊包住的礦是誘餌。它存在世界檔裡,X-Ray、freecam、種子地圖都看得到,但它跟「這裡有沒有礦」無關。
  • 唯一的觸發點是「方塊消失」:挖掘、爆炸、燃燒、活塞。事件後延一 tick,只看被移除方塊的六個面鄰居。
  • 只處理這次才第一次露出來的鄰居。本來就有一面通風的方塊,代表玩家早就看得到它,碰都不碰。
  • 對首次露出的座標跑 f(salt, world, epoch, x, y, z, 基底方塊, 維度):命中,誘餌變真礦——普通石頭也可能變礦;沒命中,誘餌變回基底方塊。

決算的當下,方塊還沒送到任何客戶端,所以老實玩家從頭到尾什麼都沒看到。作弊者對著透視裡的礦直線挖過去,挖到石頭。而且他沒辦法事先分辨哪顆是誘餌哪顆是真的,因為在挖開之前,答案還不存在。

成本上限:被移除方塊數 × 6,主執行緒,延後一 tick。沒有排程任務、沒有 ChunkLoadEvent,事件路徑上不碰資料庫。跟世界大小、線上人數都無關。

f 長什麼樣

16×16×16 的 cell。一個座標的候選礦脈來自它周圍 3×3×3 個 cell:每個 cell 拿 salt|world|epoch|礦種|cellX|cellY|cellZ 過 splitmix64 得一個 seed,由它決定這個 cell 有沒有礦脈原點、脈多大、原點落在 cell 的哪一格。同一個座標被多條脈蓋到,取 veinId 最小的。深度分布用三角形權重——preferred_y 處是 1.0,往 y_min/y_max 線性掉到 0——不然同一個機率均勻攤在動輒 300 格的高度區間,哪一層都稀得不像原版。

f 是純函數:不讀世界、不讀資料庫、沒有狀態,同輸入永遠同輸出。這換來兩個我很在意的性質。

一,冪等。懷疑哪個座標漏了事件,對它重跑一次決算,結果保證跟「當初該發生的」一致。維運指令敢直接提供 resolve 就是因為這個。

二,開源無害。真假由 salt 決定——開服第一次啟動時隨機生成、只存在你資料庫裡的一串 UUID。演算法全公開,沒有 salt 什麼都推不出來。種子地圖同理報廢:它算得出原版礦在哪,但那些位置現在全是誘餌。

cell 結果有一個 20 萬筆的 LRU cache,key 含 epoch,區塊重生後舊項目自然老化,不需要失效掃描。cache miss 就重算,純函數,沒有正確性問題。

麻煩的是「看過的不能變」

騙作弊者很簡單,不動到老實玩家看過的東西才難。兩個硬要求:礦不能在玩家盯著的牆上憑空長出來;在洞穴壁上看到的真礦,蓋起來、之後再挖開,它必須還在。

第二個比看起來難搞。想像玩家蓋住一顆生成時就露出的真礦——Kyokalith 從來沒碰過它——再挖開。這時它六面全包,對事件系統來說跟「首次露出」無法區分,決算一跑,f 沒命中,就把玩家親眼看過的真礦換成了石頭。

解法是 dirty 標記:玩家放置的方塊,加上雪/冰生成、實體放置、活塞推到的目的地,全部標 dirty。dirty 的位置永不進決算,而且挖掉 dirty 方塊也不解析它的鄰居——被蓋住的東西,在被蓋住之前必然露出過,那次露出時該發生的決算都發生完了。活塞把最後一層遮蔽拉走也算移除,所以拿活塞抽牆偷看未決算的誘餌也行不通。

dirty 旗標丟不得:它一遺失,被蓋住的方塊就重新變回「可決算」,上面那個把真礦抹掉的洞馬上回來。所以它存 SQLite 開 WAL,同步排程每 40 tick 寫回一次。這個間隔兩邊都不能亂調——調小等於每 tick 在主執行緒寫 SQLite,調大等於當機時的遺失視窗變大。

順帶一提,f 是確定性的,所以 dirty 防的從來不是「重骰」,沒有骰可以重。防的是決算跑在不該跑的位置上,把玩家已經看過的世界改掉。這個詞我自己也寫錯過,被抓出來才改。

區塊重生

Lycohinya 有跑野外還原,用的是 NatureRevive-brilliant(BrilliantTeam 維護、支援到 26.2 的版本),沒人動的區塊過一陣子會被整個重生。這種「世界會自己變動」的東西通常是插件的天敵,對誘餌模型倒是剛好相反:重生會把原版礦放回來,而原版礦本來就是誘餌,等於區塊每重生一次,誘餌就自動補滿一次,補礦這邊一行都不用寫。

要做的只有一件事:讓 f 對這個區塊重新取樣。f 是確定性的,輸入不變,重生前後算出來的答案就一模一樣,所以每個區塊記一個 epoch 計數器,重生時 +1。epoch 本來就是 f 的輸入之一,加一之後那個區塊的結果整批換新,世界其他地方不動。

橋接是用反射去掛 NatureRevive 的區塊重生事件,伺服器沒裝它,這段邏輯就完全不會載入。重生進行中,那個區塊的決算先暫停;橋接要是拋了例外,就讓它停在暫停不恢復。那個區塊從此不出礦當然難看,但總比在狀態不明的時候繼續算下去好。

放出來了

v1.2.0 在 GitHub,TYUSL 授權,免費。Spigot / Paper / Folia 26.1.2、Java 25,零依賴(Kotlin stdlib 跟 SQLite driver 由 Bukkit library loader 開服時下載)。11 種原版礦內建,加礦種、調深度分布改 config 就好,不用寫程式。

挖礦獎勵不做在裡面。它只對外發一個 OreCheckTriggerEvent——Lycohinya 拿它接挖礦檢定,你可以拿它接任何東西,沒人接它就安靜。

repo(含繁中文件):github.com/TinyYana/Kyokalith。已上架 SpigotMC 與 Hangar,Modrinth 審核中。