经济系统
经济系统提供服务器校验的货币管理、一个拍卖行(全球交易链接)和服务器端商店交易。这些服务均由 NakamaManager 自动初始化(全球交易站 Gts 和库存同步 Inventory 就位于它们旁边)。

角落里的余额并不是客户端保存的数字:它是钱包服务最后一次从服务器得知的值,而服务器也是唯一能改变它的东西。过滤条件(类别、价格、排序)会放进查询中 —— 浏览器从不下载整份目录再在本地过滤,这正是 让大型市场保持低成本的原因。

直接交易会经过顶部所标出的四个阶段:invite、offering、confirm、done。左下角的两把挂锁都必须合上,Confirm Trade 才有意义,而状态转换由服务器掌控 —— 一个不按顺序就宣称“done”的客户端会被拒绝,这正是状态机位于服务器端、而不是你眼前这个窗口里的全部原因。
架构概览
| 类 | 文件 | 职责 |
|---|---|---|
WalletService | Economy/EconomyServices.cs | 通过 Nakama 钱包进行多货币余额追踪 |
ShopService | Economy/EconomyServices.cs | 服务器校验的 NPC 商店买入/卖出 |
GTLService | Economy/EconomyServices.cs | 用于玩家间交易的全球交易链接拍卖行 |
通过 NakamaManager.Instance 访问这三者:
var wallet = NakamaManager.Instance.Wallet;
var shop = NakamaManager.Instance.Shop;
var gtl = NakamaManager.Instance.GTL;
WalletService
WalletService 管理玩家的多货币余额。Nakama 的服务器端钱包系统确保货币变更是原子的且防篡改的。
检查余额
// Refresh from server
await wallet.RefreshBalanceAsync(userId);
// Get balance for a specific currency (defaults to "gold")
long gold = wallet.GetBalance("gold");
long premiumGems = wallet.GetBalance("gems");
// Access the full currency dictionary
Dictionary<string, long> currencies = wallet.Currencies;
事件
wallet.OnBalanceChanged += (long newBalance) => { /* gold changed */ };
wallet.OnCurrencyChanged += (string currencyId, long oldValue, long newValue) =>
{
Debug.Log($"{currencyId}: {oldValue} -> {newValue}");
};
服务器权威
当 OnlineConfig.EnableServerAuthority 为 true 时,所有金钱变更都经由 OnlineCommandBridge:
// This is called internally by CommandGraph nodes (GivePlayerMoney, etc.)
bool approved = await bridge.RequestGiveMoneyAsync(amount: 500, source: "npc_reward");
服务器在应用变更前会校验来源和数额。这防止了金钱复制漏洞。
ShopService
ShopService 处理带服务器端校验的 NPC 商店交易。
购买道具
bool success = await shop.BuyFromShopAsync(
shopId: "pokemart_viridian",
itemId: "potion",
quantity: 5);
服务器校验:
- 商店存在且包含该道具。
- 玩家拥有足够的货币。
- 道具可用(对于限量道具未售罄)。
出售道具
long revenue = await shop.SellToShopAsync(
itemId: "nugget",
quantity: 1);
// revenue = the amount of gold received
服务器在记入货币前校验玩家的库存中确实拥有该道具。
全球交易链接(GTL)
GTL 是一个拍卖行系统,允许玩家将怪物和道具挂单,向全球其他玩家出售。它的功能类似于热门怪物捕捉游戏中的 GTS/GTE,但带有完整的道具支持和挂单手续费。
配置
| 配置字段 | 默认值 | 说明 |
|---|---|---|
GTLListingFee | 0.05(5%) | 从挂单价格中扣除的手续费百分比 |
GTLListingDurationHours | 48 | 挂单过期前的小时数 |
MaxGTLListings | 20 | 每名玩家的最大活跃挂单数 |
挂单一只怪物
GTLListing listing = await gtl.ListMonsterAsync(
monsterId: "monster_uuid_here",
price: 50000);
// listing.Id -- unique listing ID
// listing.Fee -- the listing fee (price * GTLListingFee)
// listing.ExpiresAt -- Unix timestamp when the listing expires
// listing.Status -- GTLListingStatus.Active
挂单一个道具
GTLListing listing = await gtl.ListItemAsync(
itemId: "rare_candy",
quantity: 10,
price: 25000);
搜索 GTL
var filter = new GTLSearchFilter
{
Query = "fire",
Type = GTLItemType.Monster,
MinPrice = 1000,
MaxPrice = 100000,
SortBy = "price",
Ascending = true,
Page = 0,
PageSize = 20
};
List<GTLListing> results = await gtl.SearchAsync(filter);
GTLSearchFilter 支持:
Query—— 对道具/怪物名称的文本搜索。Type—— 按GTLItemType.Monster或GTLItemType.Item过滤。MinPrice/MaxPrice—— 价格范围过滤。SortBy—— 排序字段名。Ascending—— 排序方向。Page/PageSize—— 分页(默认页大小:20)。
购买一个挂单
bool success = await gtl.BuyListingAsync(listingId);
取消一个挂单
bool cancelled = await gtl.CancelListingAsync(listingId);
查看你的挂单
List<GTLListing> myListings = await gtl.GetMyListingsAsync();
GTL 事件
gtl.OnListingSold += (GTLListing listing) =>
{
Debug.Log($"Your {listing.ItemName} sold for {listing.Price}!");
};
gtl.OnListingExpired += (GTLListing listing) =>
{
Debug.Log($"Listing for {listing.ItemName} expired.");
};
GTL 数据模型
GTLListing 类包含:
| 字段 | 类型 | 说明 |
|---|---|---|
Id | string | 唯一挂单 ID |
SellerId | string | 卖家用户 ID |
SellerName | string | 卖家显示名称 |
Type | GTLItemType | Monster 或 Item |
ItemId | string | 怪物或道具的 ID |
ItemName | string | 显示名称 |
Quantity | int | 数量(用于道具) |
Price | long | 要价 |
Fee | long | 扣除的挂单手续费 |
Status | GTLListingStatus | Active、Sold、Expired 或 Cancelled |
ExpiresAt | long | Unix 时间戳(毫秒) |
CreatedAt | long | Unix 时间戳(毫秒) |
客户端 UI
GTL 附带一个真实、已接线的客户端 UI。GTLBrowserUgui
(Assets/OpenMon/Online/UI/UGUI/GTLBrowserUgui.cs)是一个 UGUI 拍卖行浏览器,
它直接调用 NakamaManager.Instance.GTL —— SearchAsync、
GetMyListingsAsync、BuyListingAsync —— 并订阅 OnListingSold。当未连接时,
它会回退到一个诚实的"Offline — the GTL is unavailable"状态。
除了上文记录的返回 bool 的方法之外,GTLService 还暴露了
*WithResultAsync 变体(SearchListingsAsync、BuyListingWithResultAsync、
CancelListingWithResultAsync 等),它们返回 OnlineResult<T>,使调用方能够
呈现服务器错误,而非一个赤裸裸的 false。
GTS —— 全球交易站
第二个交易所 GTS 是怪物换怪物的异步交易。GtsService(NakamaManager.Instance.Gts,位于 Economy/EconomyServices.cs)是它在服务器 RPC gts_deposit、gts_search 和 gts_withdraw 之上的客户端传输层:
| 方法 | RPC | 作用 |
|---|---|---|
DepositAsync / DepositWithResultAsync | gts_deposit | 把队伍中的一只怪物托管起来,并将其从玩家队伍中移出,同时发布想要换回的东西(一个 GtsWantSpec)。服务器会立即寻找能同时满足双方的存入:若有,则两只怪物在同一次调用中原子地互换;否则,该存入会留在板上 |
SearchAsync / SearchWithResultAsync | gts_search | 读取处于有效期内的活跃存入所组成的板(只读:这里不会交换任何东西)。你提供的和你想要的可以预先过滤这块板 |
WithdrawAsync / WithdrawWithResultAsync | gts_withdraw | 取回你自己的一项活跃存入 |
与 GTL 一样,每个操作都有一个面向 UI 的重载(会降级为空列表或 false),以及一个返回带有服务器错误的 OnlineResult<T> 的 *WithResultAsync 重载。怪物可供匹配的卡片由服务器根据被托管的怪物在服务端推导,因此不会信任任何客户端发来的卡片。其背后的接口是 IGtsTransport,因此 UI 可以针对一个伪造对象进行测试。
GtsBrowserUgui(Assets/OpenMon/Online/UI/UGUI/GtsBrowserUgui.cs)是 UGUI 前端:它根据自己的过滤控件组装一个 GtsWantSpec,并使用纯粹的 GtsMonsterMatcher(无 Unity、无 Nakama)来过滤存入板。
经济平衡
MCE 的经济设计包含若干内建的货币回收口:
- GTL 挂单手续费(默认 5%) —— 每次交易都从流通中移除货币。
- 服务器校验的商店价格 —— 服务器控制买/卖比率,防止套利。
- 服务器端控制的战斗奖励 —— 服务器决定经验、金钱和道具掉落。
- 反作弊库存审计 ——
InventoryAuditor检测道具复制和数量不匹配(详情参见反作弊部分)。
反欺诈措施
当 EnableServerAuthority 激活时:
- 所有货币变更都经由
OnlineCommandBridge.RequestGiveMoneyAsync(),它调用mce_give_moneyRPC。服务器在应用前校验来源(战斗奖励、商店销售、任务等)。 - 所有道具变更都经由
OnlineCommandBridge.RequestGiveItemAsync(),它调用mce_give_itemRPC。 InventoryAuditor在每次已校验的行动后拍摄快照,并将其与客户端上报的库存比对。不匹配会触发AuditViolation事件。ActionRateLimiter防止快速连续的买/卖漏洞(AntiCheatManager 中配置的默认值为 0.3s;类默认值为 0.5s)。