实战案例 8:Builder 竞赛系统(链上排行榜 + 自动奖励)
目标: 构建一套链上竞赛框架:在固定时间窗口内,玩家通过质押积分参与竞赛,排行榜记录链上,到时间自动结算,前三名获得 NFT 奖杯和代币奖励。
状态:代码骨架。仓库内已附
Move.toml、weekly_race.move和 dApp 目录,但积分上报授权、奖励资产类型与链下结算来源仍需按你的赛事业务补齐。
对应代码目录
最小调用链
创建赛事 -> 充值奖池 -> 链下聚合积分 -> 服务器授权上报积分 -> 到期结算 -> 分发奖池与奖杯
链下职责边界
本案例最容易写坏的地方,不是排行榜本身,而是链下协作边界。建议把职责拆清:
- 链上只负责:赛事生命周期、奖池资金、最终结算、奖杯铸造
- 服务器负责:监听跳跃事件、按赛季聚合分数、为积分上报签名
- 前端负责:展示当前积分、触发管理员操作、读取结算结果
如果你暂时补不齐服务器签名和积分聚合,不要把本案例宣传成“完整自动化比赛系统”;更准确的表述是“赛事合约骨架 + 排行榜结算模型”。
需求分析
场景: 你(Builder)每周举办“矿区争夺赛“,比谁在本周通过你的星门跳跃最多次:
- 📅 赛制:每周日 00:00 UTC 开始,下周六 23:59 结束
- 📊 积分:每次跳跃 +1 积分(通过监听 GateJumped 事件上报)
- 🏆 奖励:
- 🥇 第一名:Champion NFT 奖杯 + 500 ALLY Token
- 🥈 第二名:Elite NFT 奖杯 + 200 ALLY Token
- 🥉 第三名:Contender NFT 奖杯 + 100 ALLY Token
- 💡 关键:前三名由合约根据链上积分自动决定,不可人工干预
第一部分:竞赛合约
module competition::weekly_race;
use sui::table::{Self, Table};
use sui::object::{Self, UID, ID};
use sui::clock::Clock;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::balance::{Self, Balance};
use sui::event;
use sui::transfer;
use std::string::{Self, String, utf8};
// 说明:此处省略了实际项目中的 `AdminACL` / `verify_sponsor`
// 导入与链下排行榜聚合逻辑,示例只展示合约建模方式。
// ── 常量 ──────────────────────────────────────────────────
const WEEK_DURATION_MS: u64 = 7 * 24 * 60 * 60 * 1000; // 7 天
// ── 数据结构 ───────────────────────────────────────────────
/// 竞赛(每周创建一个新的)
public struct Race has key {
id: UID,
season: u64, // 第几届
start_time_ms: u64,
end_time_ms: u64,
scores: Table<address, u64>, // 玩家地址 → 积分
top3: vector<address>, // 前三名(结算后填充)
is_settled: bool,
prize_pool_sui: Balance<SUI>,
admin: address,
}
/// 奖杯 NFT
public struct TrophyNFT has key, store {
id: UID,
season: u64,
rank: u8, // 1, 2, 3
score: u64,
winner: address,
image_url: String,
}
public struct RaceAdminCap has key, store { id: UID }
// ── 事件 ──────────────────────────────────────────────────
public struct ScoreUpdated has copy, drop {
race_id: ID,
player: address,
new_score: u64,
}
public struct RaceSettled has copy, drop {
race_id: ID,
season: u64,
winner: address,
second: address,
third: address,
}
// ── 初始化 ────────────────────────────────────────────────
fun init(ctx: &mut TxContext) {
transfer::transfer(RaceAdminCap { id: object::new(ctx) }, ctx.sender());
}
/// 创建新一届竞赛
public fun create_race(
_cap: &RaceAdminCap,
season: u64,
clock: &Clock,
ctx: &mut TxContext,
) {
let start = clock.timestamp_ms();
let race = Race {
id: object::new(ctx),
season,
start_time_ms: start,
end_time_ms: start + WEEK_DURATION_MS,
scores: table::new(ctx),
top3: vector::empty(),
is_settled: false,
prize_pool_sui: balance::zero(),
admin: ctx.sender(),
};
transfer::share_object(race);
}
/// 充值奖池
public fun fund_prize_pool(
race: &mut Race,
_cap: &RaceAdminCap,
coin: Coin<SUI>,
) {
balance::join(&mut race.prize_pool_sui, coin::into_balance(coin));
}
// ── 积分上报(由赛事服务器或炮塔/星门扩展调用) ────────────
public fun report_score(
race: &mut Race,
player: address,
score_delta: u64, // 本次增加的积分
clock: &Clock,
admin_acl: &AdminACL, // 需要游戏服务器签名
ctx: &TxContext,
) {
verify_sponsor(admin_acl, ctx); // 验证是授权服务器
assert!(!race.is_settled, ERaceEnded);
assert!(clock.timestamp_ms() <= race.end_time_ms, ERaceEnded);
if !table::contains(&race.scores, player) {
table::add(&mut race.scores, player, 0u64);
};
let score = table::borrow_mut(&mut race.scores, player);
*score = *score + score_delta;
event::emit(ScoreUpdated {
race_id: object::id(race),
player,
new_score: *score,
});
}
// ── 结算(需要链下算出前三名后传入)────────────────────────
public fun settle_race(
race: &mut Race,
_cap: &RaceAdminCap,
first: address,
second: address,
third: address,
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(!race.is_settled, EAlreadySettled);
assert!(clock.timestamp_ms() >= race.end_time_ms, ERaceNotEnded);
// 验证链上积分(防止传入假排名)
let s1 = *table::borrow(&race.scores, first);
let s2 = *table::borrow(&race.scores, second);
let s3 = *table::borrow(&race.scores, third);
assert!(s1 >= s2 && s2 >= s3, EInvalidRanking);
race.is_settled = true;
race.top3 = vector[first, second, third];
// 分发奖池:50% 给第一,30% 给第二,20% 给第三
let total = balance::value(&race.prize_pool_sui);
let prize1 = coin::take(&mut race.prize_pool_sui, total * 50 / 100, ctx);
let prize2 = coin::take(&mut race.prize_pool_sui, total * 30 / 100, ctx);
let prize3 = coin::take(&mut race.prize_pool_sui, balance::value(&race.prize_pool_sui), ctx);
transfer::public_transfer(prize1, first);
transfer::public_transfer(prize2, second);
transfer::public_transfer(prize3, third);
// 铸造奖杯 NFT
mint_trophy(race.season, 1, s1, first, ctx);
mint_trophy(race.season, 2, s2, second, ctx);
mint_trophy(race.season, 3, s3, third, ctx);
event::emit(RaceSettled {
race_id: object::id(race),
season: race.season,
winner: first,
second,
third,
});
}
fun mint_trophy(
season: u64,
rank: u8,
score: u64,
winner: address,
ctx: &mut TxContext,
) {
let (name, image_url) = match(rank) {
1 => (b"Champion Trophy", b"https://assets.example.com/trophies/gold.png"),
2 => (b"Elite Trophy", b"https://assets.example.com/trophies/silver.png"),
_ => (b"Contender Trophy", b"https://assets.example.com/trophies/bronze.png"),
};
let trophy = TrophyNFT {
id: object::new(ctx),
season,
rank,
score,
winner,
image_url: utf8(image_url),
};
transfer::public_transfer(trophy, winner);
}
const ERaceEnded: u64 = 0;
const EAlreadySettled: u64 = 1;
const ERaceNotEnded: u64 = 2;
const EInvalidRanking: u64 = 3;
第二部分:结算脚本(链下排名 + 链上结算)
// scripts/settle-race.ts
import { SuiClient } from "@mysten/sui/client"
import { Transaction } from "@mysten/sui/transactions"
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"
const RACE_PKG = "0x_COMPETITION_PACKAGE_"
const RACE_ID = "0x_RACE_ID_"
async function settleRace() {
const client = new SuiClient({ url: "https://fullnode.testnet.sui.io:443" })
const adminKeypair = Ed25519Keypair.fromSecretKey(/* ... */)
// 1. 从链上读取所有积分(通过 ScoreUpdated 事件聚合)
const scoreMap = new Map<string, number>()
let cursor = null
do {
const page = await client.queryEvents({
query: { MoveEventType: `${RACE_PKG}::weekly_race::ScoreUpdated` },
cursor,
limit: 200,
})
for (const event of page.data) {
const { player, new_score } = event.parsedJson as any
scoreMap.set(player, Number(new_score)) // 取最新值
}
cursor = page.nextCursor
} while (cursor)
// 2. 排序找出前三名
const sorted = [...scoreMap.entries()]
.sort((a, b) => b[1] - a[1])
if (sorted.length < 3) {
console.log("参与人数不足,无法结算")
return
}
const [first, second, third] = sorted.slice(0, 3).map(([addr]) => addr)
console.log(`第一:${first}(${sorted[0][1]} 积分)`)
console.log(`第二:${second}(${sorted[1][1]} 积分)`)
console.log(`第三:${third}(${sorted[2][1]} 积分)`)
// 3. 提交结算交易
const tx = new Transaction()
tx.moveCall({
target: `${RACE_PKG}::weekly_race::settle_race`,
arguments: [
tx.object(RACE_ID),
tx.object("ADMIN_CAP_ID"),
tx.pure.address(first),
tx.pure.address(second),
tx.pure.address(third),
tx.object("0x6"), // Clock
],
})
const result = await client.signAndExecuteTransaction({
signer: adminKeypair,
transaction: tx,
})
console.log("结算成功!奖杯已发放。Tx:", result.digest)
}
settleRace()
第三部分:实时排行榜 dApp
// src/LeaderboardApp.tsx
import { useEffect, useState } from 'react'
import { useRealtimeEvents } from './hooks/useRealtimeEvents'
const RACE_PKG = "0x_COMPETITION_PACKAGE_"
interface ScoreEntry {
rank: number
address: string
score: number
}
export function LeaderboardApp() {
const [scores, setScores] = useState<Map<string, number>>(new Map())
const [timeLeft, setTimeLeft] = useState('')
const raceEnd = new Date('2026-03-08T00:00:00Z').getTime()
// 实时订阅积分更新
const events = useRealtimeEvents<{ player: string; new_score: string }>(
`${RACE_PKG}::weekly_race::ScoreUpdated`
)
useEffect(() => {
const updated = new Map(scores)
for (const e of events) {
updated.set(e.player, Number(e.new_score))
}
setScores(updated)
}, [events])
// 倒计时
useEffect(() => {
const timer = setInterval(() => {
const diff = raceEnd - Date.now()
if (diff <= 0) { setTimeLeft('已结束'); return }
const d = Math.floor(diff / 86400000)
const h = Math.floor((diff % 86400000) / 3600000)
const m = Math.floor((diff % 3600000) / 60000)
setTimeLeft(`${d}天 ${h}时 ${m}分`)
}, 1000)
return () => clearInterval(timer)
}, [])
const sorted: ScoreEntry[] = [...scores.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([address, score], i) => ({ rank: i + 1, address, score }))
const medals = ['🥇', '🥈', '🥉']
return (
<div className="leaderboard">
<header>
<h1>🏆 第一届星门跳跃竞赛</h1>
<div className="countdown">
⏳ 剩余时间:<strong>{timeLeft}</strong>
</div>
</header>
<table className="ranking-table">
<thead>
<tr><th>排名</th><th>玩家</th><th>跳跃次数</th></tr>
</thead>
<tbody>
{sorted.map(({ rank, address, score }) => (
<tr key={address} className={rank <= 3 ? 'top3' : ''}>
<td>{medals[rank - 1] ?? rank}</td>
<td>{address.slice(0, 6)}...{address.slice(-4)}</td>
<td><strong>{score}</strong> 次</td>
</tr>
))}
{sorted.length === 0 && (
<tr><td colSpan={3}>暂无数据,等待第一次跳跃...</td></tr>
)}
</tbody>
</table>
</div>
)
}
🎯 完整回顾
合约层
├── weekly_race.move
│ ├── Race(共享对象,每届一个)
│ ├── TrophyNFT(奖杯对象)
│ ├── create_race() ← Admin 创建
│ ├── fund_prize_pool() ← Admin 充奖池
│ ├── report_score() ← 服务器上报积分(AdminACL 验证)
│ └── settle_race() ← Admin 传入前三名,合约验证并结算
结算脚本
└── settle-race.ts
├── QueryEvents 聚合所有积分
├── 排序计算前三名
└── 提交 settle_race() 交易
dApp 层
└── LeaderboardApp.tsx
├── subscribeEvent 实时更新排行榜
└── 竞赛倒计时
🔧 扩展练习
- 防刷分:在
report_score中限速(每个玩家每分钟最多上报 60 积分) - 公开验证:将每次积分上报的原始数据哈希也存链上,让任何人可以验算最终排名
- 赛季制:Admin 无法提前结束当届竞赛,合约强制执行时间轴