概覽
JACKZ.AI 公平驗證是一個獨立的驗證頁。平台在每一筆抽卡紀錄公開驗證需要的資料,玩家自己把資料貼到驗證頁,頁面會在瀏覽器裡重算那一抽,算出中還是不中。由玩家自己輸入、自己按 Verify,結果才由玩家親手核對。
平台不用安裝 SDK,也不用開 API。計算全部在玩家的瀏覽器完成,資料不會送到伺服器。想讓玩家少打字,可以另外做一鍵連結(見「驗證連結 v1」),但這不是必須的。
名詞
文件和驗證頁都用以下英文名詞。
- Server Seed
- 平台產生的隨機字串。建議用 32 個位元組的密碼學隨機數,以十六進制表示。換種子之前保密。
- Server Seed Hash
- SHA-256(Server Seed) 的十六進制小寫字串,在抽之前公開。
- Client Seed
- 玩家的種子,由玩家設定或平台隨機產生,區分大小寫。
- Nonce
- 同一組種子下的抽卡序號,十進制整數,每抽一次加一。
- Draw
- 同一個 Nonce 裡的第幾抽,由 0 開始。十連抽可以共用一個 Nonce,用 Draw 0 至 9 區分。
- Roll
- 由以上資料算出的 32 位元無號整數,範圍是 0 至 4,294,967,295。
- Odds(weights)
- Pick 計法裡各獎項的整數權重。一番賞用抽這一張時各賞剩下的張數。
- Threshold
- Threshold 計法的成功區上限,Roll 小於它就成功。
- Seed Rotation
- 換種子:公開舊的 Server Seed,同時公開新種子的 Hash,之後的抽卡改用新種子。
- House Edge
- 平台抽成,每部機各自設定,可以隨時調整。以小數表示,例如 8% 寫 0.08,沒有抽成寫 0。卡牌合成用它計算成功率;只在選用的驗證連結裡傳給驗證頁,玩家不用填。
接入步驟
以下七步全部做到,玩家才可以獨立核對每一抽。
- 抽之前公開 Server Seed Hash產生 Server Seed,在玩家抽卡之前顯示它的 Hash。可以的話同時寫上鏈或存證,之後不可更改。
- 讓玩家設定 Client Seed預設可以隨機產生,但玩家要能自己修改。玩家改 Client Seed 時,建議同時換一組新的 Server Seed。
- 每一抽用下一個 Nonce每抽一次 Nonce 加一。不可重用、不可跳號,也不可因為結果而重抽;每一個 Nonce 都要有紀錄。
- 抽之前公開 OddsPick 計法公開各獎項權重,一番賞公開各賞剩餘張數;Threshold 計法公開成功率或計算方法。
- 保存每一抽的紀錄client_seed、nonce、draw、weights、ranges 或 threshold、roll、result,連同紀錄編號一起保存。
- Seed Rotation 時公開舊種子玩家按「換種子」或種子到期時,公開舊的 Server Seed,並開始使用新的種子。
- 在紀錄公開驗證資料每一筆紀錄顯示 Server Seed Hash、Server Seed(換種子後)、Client Seed、Nonce、Draw 和這一抽的 Odds 或成功率,每項最好可以一鍵複製;旁邊放一條連到驗證頁的普通連結,不用帶資料。
算法
所有計法都先算出 Roll,再按計法把 Roll 變成結果。全程用整數運算,不經浮點數。
Roll
block = HMAC-SHA256(key = server_seed, message = "{client_seed}:{nonce}:{floor(draw / 8)}")
roll = block[4 × (draw mod 8) .. +4] 讀成大端(big-endian)無號 32 位元整數
block
roll
- server_seed 按 UTF-8 文字直接當密鑰,不要先由十六進制轉成位元組。
- 冒號是半形;nonce 和 floor(draw / 8) 用十進制,不補 0。
- 每個 HMAC 區塊有 32 個位元組,可以給 8 個 Draw 用。Draw 8 至 15 用第二個區塊,如此類推。
Threshold:成功/失敗(卡牌合成)
success = roll < threshold
成功率 = threshold ÷ 4,294,967,296
threshold = floor(投入卡市價合計 × (1 − house_edge) × 2^32 ÷ 目標卡市價)
- 市價用十進制字串,以分數運算,只在最後取整一次。投入卡和目標卡要用同一種幣別。
- threshold 大於或等於 4,294,967,296 時一定成功。
- 平台用其他合成公式的話,只公開 threshold;市價參數只用於選用的驗證連結。
- 直接公佈成功率的玩法(例如卡牌強化),threshold = floor(成功率 × 2^32),同樣以分數運算。例如成功率 0.35,threshold 是 1,503,238,553。
Pick:抽獎品(Oripa、一番賞、抽卡、盲盒)
W = 所有權重的總和
ticket = floor(roll × W ÷ 4,294,967,296)
result = 由第 0 項起累加權重,第一個累計值大於 ticket 的獎項編號
Pick(機率區間)
獎品表用小數區間表示機率的平台,用這個寫法。驗證頁的玩法選「抽獎品(按機率區間)」。
f = round(roll ÷ 4,294,967,296, 12),再 round(f, 6)
result = 第一個 begin ≤ f ≤ end 的區間編號(由 0 開始,begin 和 end 都包括在內)
- 四捨五入都是「五入」:第 13 位是 5 或以上就進位,6 位時同理。
- 區間用小數,最多 6 位,例如 0.000000-0.000999,0.001000-0.019999。
- 平台用浮點數計算時,12 位小數剛好落在 5 的極少數 Roll 可能差一位;用整數或十進制計算可以避免。
遊戲種類對應
| 遊戲種類 | 計法 | 做法 |
|---|---|---|
| Oripa オリパ | Pick | 按公開的 Odds 抽出卡牌。 |
| 一番賞 Ichiban Kuji | Pick | 用各賞剩餘張數做權重,每抽一張就更新。 |
| 抽卡 Gacha | Pick | 按稀有度 Odds 抽出角色或卡牌。 |
| 盲盒 Blind Box | Pick | 按款式 Odds 決定開出哪一款。 |
| 扭蛋 Gashapon | Pick | 每粒扭蛋按款式 Odds 抽出。 |
| 福袋 Lucky Bag | Pick | 按檔次 Odds 決定福袋內容。 |
| 神秘箱 Mystery Box | Pick | 按公開的獎品表抽出內容。 |
| 開包 Pack Opening | Pick | 每個卡位各用一個 Draw,按稀有度 Odds(權重或機率區間)抽出。分層的卡包每一層公開一組 weights、labels 和 Draw,用同一個 Nonce,Draw 按「卡位 → 層」順序連續編號(例如 5 個卡位、每位 2 層,用 Draw 0 至 9)。第一層的 weights 是各級別的權重;下一層的 weights 和 labels 是上一層抽中級別內的卡。 |
| 拆盒分配 Box Break | Pick | 隨機分配隊伍或卡位。下一抽的 weights 和 labels 去掉已抽中的項目。 |
| 抽選 Raffle | Pick | 每個登記者權重相同,抽出購買資格。抽多個名額時,同樣去掉已抽中的人。 |
| 抽獎活動 Lucky Draw | Pick | 活動獎品按名額和權重抽出。 |
| 卡牌合成 Card Upgrade | Threshold | Roll 小於平台紀錄的 Threshold 就成功;成功率由平台訂定並公開。 |
| 卡牌強化 Enhancement | Threshold | 按公開的成功率決定強化成敗。 |
- roll × W 可能超過 2^53,JavaScript 要用 BigInt。
- 每個 ticket 對應的 Roll 數量最多相差 1。W 不超過 1,000,000 時,每項機率的相對誤差小於 0.03%。
- 一番賞每抽一張,權重就換成新的剩餘張數;紀錄要公開抽那一張時的權重。
驗證連結 v1(選用)
這一節是選用功能:平台想讓玩家按一下就打開已填好資料的驗證頁時才需要。只在紀錄公開資料、讓玩家自己輸入,就不用做這一節。
連結由驗證頁網址、# 和參數組成。每個值都要 URL 編碼;驗證頁會忽略不認識的參數。
House Edge、Odds、獎品表和市價都按該部機、該一抽的設定放進連結,驗證頁沒有任何預設值。設定改了(例如 House Edge 由 5% 調成 8%),之後的連結帶新數字就可以,驗證頁不用更新。
必填
- v
- 格式版本,現在是 1。
- server_seed_hash
- 抽之前公開的 SHA-256(server_seed),十六進制。
- client_seed
- 玩家的 Client Seed。
- nonce
- 十進制整數。
換種子後必填
- server_seed
- 已公開的 Server Seed 原文。種子還在使用時不要放,驗證頁會提示玩家換種子後再驗。
平台紀錄
- result
- 平台紀錄的結果。Threshold 填 1(成功)或 0(失敗);Pick 填獎項編號,由 0 開始。沒有 result 時,驗證頁只會顯示重算結果,不會顯示「通過」。
- roll
- 選填:平台紀錄的 Roll。
- draw
- 選填:同一 Nonce 的第幾抽,由 0 開始,不填當 0。
Pick 計法
- game=pick
- 不填 game 時,有 weights 就當 Pick。
- weights
- Pick 要填 weights 或 ranges 其中一個。weights 是各獎項的整數權重,用逗號分隔,最多 1,000 項。
- ranges
- 機率區間寫法用來代替 weights:每項 begin-end,用逗號分隔,小數最多 6 位,區間互不重疊。game 填 range。
- labels
- 選填:獎項名稱,用 | 分隔,順序同 weights 或 ranges。
Threshold 計法
- game=threshold
- 成功/失敗。
- threshold
- 成功區上限,十進制整數,0 至 4,294,967,296;Roll 小於 threshold 就成功。沒有 threshold 時,驗證頁會用下面三個市價參數計算。
- input_value
target_market_price
edge_rate - 卡牌合成:投入卡市價合計、目標卡市價、House Edge,三個一起附上。有市價就必須有 edge_rate,沒有抽成寫 0;缺任何一個,驗證頁會拒絕驗證,不會自行當作 0 或其他數值。edge_rate 要是 0 至小於 1 的小數(例如 0.05),否則同樣拒絕驗證。input_value 和 target_market_price 用十進制字串,只可有數字和一個小數點(例如 41.80),不可有逗號、正負號或科學記號;target_market_price 要大於 0。同時附上 threshold 時,驗證頁會核對兩者是否一致。
- currency
- 選填:金額單位,由平台填寫,原樣顯示在市價旁邊。
- inputs
target - 投入卡全名(用 | 分隔,每張一個)和目標卡全名,顯示在結果上。
顯示用
- game_type
- 遊戲種類,例如 Oripa、一番賞、扭蛋、卡牌合成,顯示在結果上方。
- platform
order_id - 平台名稱和紀錄編號,以文字顯示在結果上方。
- chain_url
- 鏈上紀錄網址,只接受 https。
- input_ids
target_id - 卡牌 ID,順序同 inputs。
程式範例
三個版本的算法相同,都只用語言內建的函數,並已用下面的測試向量核對。PHP 版的卡牌合成 threshold 用 bcmath 擴展。
// JACKZ.AI Fair Verify v1:Node.js 18 以上,只用內建模組
import { createHash, createHmac } from "node:crypto";
const SPACE = 2n ** 32n;
// 抽之前公開:SHA-256(Server Seed),十六進制小寫
export function seedHash(serverSeed) {
return createHash("sha256").update(serverSeed, "utf8").digest("hex");
}
// 第 draw 抽的 Roll:0 至 4,294,967,295
export function roll(serverSeed, clientSeed, nonce, draw = 0) {
const block = createHmac("sha256", serverSeed)
.update(`${clientSeed}:${nonce}:${Math.floor(draw / 8)}`)
.digest();
return BigInt(block.readUInt32BE((draw % 8) * 4));
}
// Threshold:Roll 小於 threshold 就成功
export function isSuccess(r, threshold) {
return r < BigInt(threshold);
}
// 卡牌合成的 threshold;金額用十進制字串,避免浮點誤差
export function upgradeThreshold(inputValue, targetPrice, edgeRate) {
if (!/^0(\.\d+)?$/.test(edgeRate ?? "")) throw new Error("edge_rate 必填,要是 0 至小於 1 的小數;沒有抽成填 0");
if (![inputValue, targetPrice].every((s) => /^\d+(\.\d+)?$/.test(s ?? ""))) throw new Error("市價要用十進制數字");
const frac = (s) => {
const [i, f = ""] = String(s).split(".");
return [BigInt(i + f), 10n ** BigInt(f.length)];
};
const [a, ad] = frac(inputValue), [t, td] = frac(targetPrice), [e, ed] = frac(edgeRate);
return (a * (ed - e) * SPACE * td) / (ad * ed * t);
}
// Pick:weights 是各獎項的整數權重(一番賞填各賞剩餘張數),回傳獎項編號
export function pick(r, weights) {
const w = weights.map(BigInt);
const ticket = (r * w.reduce((x, y) => x + y, 0n)) / SPACE;
let cum = 0n;
for (let i = 0; i < w.length; i++) {
cum += w[i];
if (ticket < cum) return i;
}
throw new Error("weights 的總和必須大於 0");
}
// 一鍵驗證連結(Link Format v1)
export function verifyLink(base, params) {
return `${base}#${new URLSearchParams({ v: "1", ...params })}`;
}# JACKZ.AI Fair Verify v1:Python 3.8 以上,只用標準庫
import hashlib
import hmac
import re
from fractions import Fraction
from math import floor
from urllib.parse import urlencode
SPACE = 2 ** 32
def seed_hash(server_seed: str) -> str:
"""抽之前公開:SHA-256(Server Seed),十六進制小寫"""
return hashlib.sha256(server_seed.encode()).hexdigest()
def roll(server_seed: str, client_seed: str, nonce: int, draw: int = 0) -> int:
"""第 draw 抽的 Roll:0 至 4,294,967,295"""
message = f"{client_seed}:{nonce}:{draw // 8}".encode()
block = hmac.new(server_seed.encode(), message, hashlib.sha256).digest()
offset = (draw % 8) * 4
return int.from_bytes(block[offset:offset + 4], "big")
def is_success(r: int, threshold: int) -> bool:
"""Threshold:Roll 小於 threshold 就成功"""
return r < threshold
def upgrade_threshold(input_value: str, target_price: str, edge_rate: str) -> int:
"""卡牌合成的 threshold;金額用十進制字串,避免浮點誤差"""
if not re.fullmatch(r"0(\.\d+)?", edge_rate or ""):
raise ValueError("edge_rate 必填,要是 0 至小於 1 的小數;沒有抽成填 0")
if not all(re.fullmatch(r"\d+(\.\d+)?", s or "") for s in (input_value, target_price)):
raise ValueError("市價要用十進制數字")
return floor(Fraction(input_value) * (1 - Fraction(edge_rate)) * SPACE / Fraction(target_price))
def pick(r: int, weights: list) -> int:
"""Pick:weights 是各獎項的整數權重(一番賞填各賞剩餘張數),回傳獎項編號"""
ticket = r * sum(weights) // SPACE
cum = 0
for i, w in enumerate(weights):
cum += w
if ticket < cum:
return i
raise ValueError("weights 的總和必須大於 0")
def verify_link(base: str, **params) -> str:
"""一鍵驗證連結(Link Format v1)"""
return base + "#" + urlencode({"v": 1, **params})<?php
// JACKZ.AI Fair Verify v1:PHP 8.5.12 或以上(64 位元),卡牌合成需要 bcmath 擴展
const FV_SPACE = 4294967296; // 2^32
// 抽之前公開:SHA-256(Server Seed),十六進制小寫
function fv_seed_hash(string $serverSeed): string
{
return hash('sha256', $serverSeed);
}
// 第 $draw 抽的 Roll:0 至 4,294,967,295
function fv_roll(string $serverSeed, string $clientSeed, int $nonce, int $draw = 0): int
{
$block = hash_hmac('sha256', $clientSeed . ':' . $nonce . ':' . intdiv($draw, 8), $serverSeed, true);
return unpack('N', substr($block, ($draw % 8) * 4, 4))[1];
}
// Threshold:Roll 小於 threshold 就成功
function fv_is_success(int $roll, int $threshold): bool
{
return $roll < $threshold;
}
// 卡牌合成的 threshold;金額用十進制字串,需要 bcmath。edge_rate 必填,0 至小於 1,沒有抽成填 '0'
// 三個金額的小數位合共不要超過 18 位,乘積才會是準確值
function fv_upgrade_threshold(string $inputValue, string $targetPrice, string $edgeRate): string
{
if (!preg_match('/^0(\.\d+)?$/', $edgeRate)) throw new InvalidArgumentException('edge_rate 必填,要是 0 至小於 1 的小數;沒有抽成填 0');
foreach ([$inputValue, $targetPrice] as $s) if (!preg_match('/^\d+(\.\d+)?$/', $s)) throw new InvalidArgumentException('市價要用十進制數字');
$n = bcmul(bcmul($inputValue, bcsub('1', $edgeRate, 18), 18), '4294967296', 18);
return bcdiv($n, $targetPrice, 0); // scale 0 即向下取整(金額都是正數)
}
// Pick:$weights 是各獎項的整數權重(一番賞填各賞剩餘張數),回傳獎項編號
// 權重總和要小於 2^31,否則 $roll * 總和 會超出 64 位元整數,請改用 GMP 或 bcmath
function fv_pick(int $roll, array $weights): int
{
$ticket = intdiv($roll * array_sum($weights), FV_SPACE);
$cum = 0;
foreach (array_values($weights) as $i => $w) {
$cum += $w;
if ($ticket < $cum) {
return $i;
}
}
throw new InvalidArgumentException('weights 的總和必須大於 0');
}
// 一鍵驗證連結(Link Format v1)
function fv_verify_link(string $base, array $params): string
{
return $base . '#' . http_build_query(['v' => 1] + $params);
}測試向量
接入前,用以下數字核對你的實作。全部數字都可以在驗證頁重算出來。
Roll
| nonce | draw | roll |
|---|---|---|
| 1 | 0 | 2079044362 |
| 5 | 0 | 441912824 |
| 1 | 8 | 4185532748 |
| 1 | 9 | 2271708478 |
Threshold
| input_value | target_market_price | edge_rate | threshold |
|---|---|---|---|
| 40 | 100 | 0.05 | 1632087572 |
| 30.00 | 100.00 | 0.05 | 1224065679 |
| 50 | 95 | 0.05 | 2147483648 |
| 40 | 100 | 0.08 | 1580547964 |
| 41.80 | 108.50 | 0 | 1654650995 |
| 40 | 100 | (缺) | 拒絕驗證 |
| 40 | 100 | 1 | 拒絕驗證 |
Pick
| roll | weights | result |
|---|---|---|
| 441912824 | 1,4,20,75 | 2 |
| 2079044362 | 1,4,20,75 | 3 |
Pick(機率區間)
| roll | f(6 位) | ranges | result |
|---|---|---|---|
| 1163190975 | 0.270827 | 0.000000-0.270826, 0.270827-1.000000 | 1 |
| 1163190975 | 0.270827 | 0.000000-0.270827, 0.270828-1.000000 | 0 |
| 2166811 | 0.000505 | 0.000000-0.000504, 0.000505-1.000000 | 1 |
驗證範圍
驗證頁核對的是「結果由承諾的種子算出」。以下列明它能證明和不能單獨證明的事,平台公開說明時請照實寫。
驗證可以證明
- 這一抽由輸入的 Server Seed、玩家的 Client Seed 和 Nonce 算出,而這組 Server Seed 符合輸入的 Server Seed Hash。
- 平台紀錄的 Roll 和結果,和重算一致。
- 附上市價時,卡牌合成的成功率和市價、House Edge 算出來的一致。
需要另外核對
- Odds 是否和抽之前公開的一致:權重由平台提供,玩家要對照抽之前看到的 Odds。
- 有沒有跳號或重抽:用 Batch Verify 重算連續的 Nonce,再和平台的抽卡紀錄逐筆對照。
- 輸入的 Server Seed Hash,是否就是玩家抽之前看到的那一個。
- 市價本身是否合理:市價和 House Edge 由平台提供,驗證頁只核對用這些數字算出來的成功率。
- 一番賞剩餘張數是否真實,以及獎品有沒有交付。
版本
連結裡的 v 是格式版本,現行版本是 1。