feat(all): add hunting and events, commerce and rewards, binary calendars, GameData caching and login diagnostics

Implement hunting AP and progress, event tasks, exchanges, games, stories and actions; connect battle and reward handling.

Add CashShop plugin and installation, server purchases and entitlements, recharge rules and plugin version metadata.

Distribute binary calendars independently; cache GameData in memory and fix single-connection query deadlocks.

Improve automatic login and recovery diagnostics; document configuration and release flows.
This commit is contained in:
2026-10-05 11:50:31 +08:00
parent eb445f88f4
commit d8771caef2
222 changed files with 19000 additions and 4284 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ plugins/**/Directory.Build.props
plugins/Directory.Build.local.props
# Project notes and local/runtime data are intentionally outside source control
docs/
docs/*
data/
# Release and language build products
+1
View File
@@ -19,6 +19,7 @@ WORKDIR /app
COPY --from=build /out/bd2server /app/bd2server
COPY versions.json authentication.json resources.json game.json /app/
COPY go/seed /app/go/seed
COPY schedules /app/schedules
RUN mkdir -p /app/data && chown 10001:10001 /app/data
USER 10001:10001
+2
View File
@@ -5,3 +5,5 @@
!authentication.json
!resources.json
!game.json
!schedules/
!schedules/**
+40
View File
@@ -14,6 +14,13 @@
},
"gacha": {
"include_collaboration_ur_weapons": false
},
"purchases": {
"diamond_recharge": {
"currency": "free",
"gold_per_paid_diamond": 1000,
"diamonds_per_paid_diamond": 1
}
}
}
```
@@ -44,6 +51,39 @@
后续官方若将旧主线与店长篇重构合并为一条剧情线,再重新评估此限制与迁移方案。重构发布日期尚未确认。
## 现金商品与付费钻石充值
所有原本走平台现金支付的普通商品,包括礼包、皮肤、通行证与付费抽卡商品,都固定消耗付费钻石。这类商品没有免费购买配置开关;`purchases.diamond_recharge` 仅设置付费钻石充值的兑换方式。原本已使用游戏内货币的商品仍走原来的游戏内购买流程。
普通现金商品的价格由当前版本 GameData 计算:取最小常规充值档位的现金标价与付费钻石数量作为换算基准,将商品现金标价换算为付费钻石并向上取整。当前 2.35.10 的最小档位为现金标价 `2200`、常规付费钻石 `100`,因此普通商品价格为 `ceil(现金标价 / 22)`。运行时读取 GameData 的实际档位,不把该比值固定写入代码。首次充值的加倍奖励不参与换算基准,也不增加充值兑换费用。
`purchases.diamond_recharge.currency` 接受下列值:
| 值 | 付费钻石充值行为 |
|---|---|
| `"free"`(默认) | 不扣货币,发放所选充值商品的原始奖励。 |
| `"gold"` | 每单位常规付费钻石消耗 `gold_per_paid_diamond` 金币。 |
| `"diamonds"` | 每单位常规付费钻石消耗 `diamonds_per_paid_diamond` 普通钻石。 |
| `"ban"` 或 `""` | 禁止购买付费钻石充值商品。 |
省略该配置分组或 `currency` 时使用 `"free"`;显式写空字符串 `""` 会禁止充值。`null` 仍属于无效配置。兑换倍率必须是 `1` 到 `1000000000` 之间的整数,默认分别为 `1000` 金币与 `1` 普通钻石。即使选中其他兑换方式,两个倍率也必须合法。配置导致任何实际商品单次费用超出游戏钱包支持的整数范围时,服务端拒绝加载该商品目录;多件购买超过范围时拒绝该次购买。
例如,使用金币兑换充值:
```json
"purchases": {
"diamond_recharge": {
"currency": "gold",
"gold_per_paid_diamond": 1000,
"diamonds_per_paid_diamond": 1
}
}
```
充值的兑换费用按常规充值数量计算,实际发放仍遵守所选商品的奖励和首购限制。因此常规数量为 `100` 的首购加倍商品在默认金币倍率下扣 `100000` 金币,保留原来的加倍奖励;后续常规商品扣相同费用。禁止充值不会免费化其他现金商品,也不会删除玩家已有的付费钻石。
客户端从服务端 `/client/commerce` 获取商品价格目录。商品由 `group_id`、`product_id`、`sale_group` 三元组识别,Google 与 Apple 平台商品编号均作为别名返回;同一平台编号对应首购与常规变体时仍保留不同商品键。显示价格和购买校验使用同一服务端规则。
## 校验与升级
`schema_version` 当前必须为 `1`。字段拼写错误、未知字段、错误类型(例如字符串 `"true"`)、`null`、格式错误或文件末尾多出的 JSON 都会使服务端拒绝启动,并在错误中给出文件路径。省略 `gacha` 或此布尔开关时使用 `false`。
+1 -1
View File
@@ -60,7 +60,7 @@ BD2 Client Studio 是使用 Wails 的独立桌面客户端设置工具。Windows
- 选择并验证 Brown Dust II 安装目录。
- 填写服务器 origin 和端口,自动写入游戏目录下的 `BepInEx/config/bd2.client.json`。
- 应用带原文件备份的客户端入口补丁。
- 检查 BepInEx 后安装或更新 `BD2LocalIdentity.dll`、`BD2LoginUI.dll` 及其共享运行时 `BD2.GameNames.dll`。
- 检查 BepInEx 后安装或更新 `BD2LocalIdentity.dll`、`BD2LoginUI.dll`、`BD2CashShop.dll` 及其共享运行时 `BD2.GameNames.dll`。
- 选择官方 CDN、服务器资源源或本地已下载资源。
服主同步官方资源、自建静态 CDN 或配置缓存反代的步骤见 [资源与 CDN 配置指南](RESOURCES.md)。
+5 -1
View File
@@ -26,7 +26,7 @@
curl --fail --silent --show-error http://127.0.0.1:8080/readyz
```
5. 让玩家使用独立的 `bd2client.exe` 选择游戏目录、填写服务器地址、选择 CDN,并安装 `BD2LocalIdentity.dll` 与 `BD2LoginUI.dll`。客户端工具说明见 `README.md`(客户端包)或仓库的 `docs/CLIENT.md`。
5. 让玩家使用独立的 `bd2client.exe` 选择游戏目录、填写服务器地址、选择 CDN,并安装 `BD2LocalIdentity.dll`、`BD2LoginUI.dll` 与 `BD2CashShop.dll`。客户端工具说明见 `README.md`(客户端包)或仓库的 `docs/CLIENT.md`。
`versions.json` 是服务端和客户端插件共用的版本选择,必须与各自可执行文件保持在同一目录。`game_version` 锁定官方游戏兼容版本;`client_version` 与 `server_version` 分别标识客户端工具和纯服务端发布版本,并以 `游戏版本+组件.X.Y.Z` 组合。服务端从包内 `go/seed` 读取种子,不依赖当前工作目录。需要临时测试另一组版本时可传 `serve --version-config <文件>`。玩家存档位于 `data\state\state.db`;服务端以 Go 迁移器按相邻版本升级,并在同一个 SQLite 事务内完成迁移和最终校验,失败时不会留下部分升级。
@@ -34,4 +34,8 @@
游戏规则使用同目录的 `game.json`,修改后重启服务端生效。默认关闭联动 UR 专武扩展;需要将 14 种联动角色专武加入 UR 必得装备券池时,按 [服务端游戏规则配置](GAME_CONFIGURATION.md) 设置。升级时保留已修改的文件;旧安装缺少文件时使用默认规则。
排期由项目维护,存放在 `bd2server.exe` 同目录的 `schedules/` 中,使用真正的自定义二进制格式 `.bd2schedule`。服务端启动时读取其中所有排期文件并统一下发。完整服务端包包含同一套排期;排期文件也可以单独分发,无需更新服务端可执行文件。排期更新时停止服务端,用发布的排期文件完整替换旧 `schedules/` 目录,再启动服务端,避免遗留旧文件继续生效。排期文件只适用于发布目录名称标明的游戏版本。
维护者仅发布排期时,在仓库根目录运行 `./build-release.ps1 -SchedulesOnly`,独立文件输出到 `.build/bd2schedules-<游戏版本>-<内容哈希>/`,无需打包 ZIP。此命令无需游戏客户端目录,也不会构建服务端、客户端或插件。服主直接安装项目发布的排期文件。格式与维护说明见 [排期数据说明](docs/SCHEDULES.md)。
OAuth access token 只在客户端内存中;Windows refresh credential 使用当前用户 DPAPI 加密后写入 PlayerPrefs 注册表,macOS 使用 Keychain Services。服务端 `auth.db` 只保存 token、device secret、OAuth state 及 provider subject 的 HMAC,临时可恢复材料使用 AES-256-GCM。
+36 -2
View File
@@ -1,7 +1,8 @@
[CmdletBinding()]
param(
[string]$GameDir,
[switch]$SkipTests
[switch]$SkipTests,
[switch]$SchedulesOnly
)
$ErrorActionPreference = 'Stop'
@@ -18,6 +19,7 @@ $versionConfig = Join-Path $root 'versions.json'
$authenticationConfig = Join-Path $root 'authentication.json'
$resourceConfig = Join-Path $root 'resources.json'
$gameConfig = Join-Path $root 'game.json'
$scheduleDirectory = Join-Path $root 'schedules'
try {
$releaseVersions = Get-Content -LiteralPath $versionConfig -Raw | ConvertFrom-Json -ErrorAction Stop
@@ -27,6 +29,32 @@ try {
$serverArchive = Join-Path $buildRoot ("bd2server-{0}-windows-x64.zip" -f $releaseVersions.server_version)
$clientArchive = Join-Path $buildRoot ("bd2client-{0}-windows-x64.zip" -f $releaseVersions.client_version)
# Hash both filenames and contents so schedule releases evolve independently of
# the server executable and distinguish additions, removals and renamed files.
$scheduleFiles = @(Get-ChildItem -LiteralPath $scheduleDirectory -File -Filter '*.bd2schedule' | Sort-Object Name)
if ($scheduleFiles.Count -eq 0) { throw "No project schedule files found at $scheduleDirectory" }
$scheduleManifest = ($scheduleFiles | ForEach-Object {
"{0}:{1}" -f $_.Name, (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash
}) -join "`n"
$scheduleHasher = [Security.Cryptography.SHA256]::Create()
try {
$scheduleHash = ([BitConverter]::ToString($scheduleHasher.ComputeHash([Text.Encoding]::UTF8.GetBytes($scheduleManifest)))).Replace('-', '').ToLowerInvariant().Substring(0, 12)
} finally { $scheduleHasher.Dispose() }
$scheduleRelease = Join-Path $buildRoot ("bd2schedules-{0}-{1}" -f $releaseVersions.game_version, $scheduleHash)
function Write-ScheduleRelease {
New-Item -ItemType Directory -Force -Path $scheduleRelease | Out-Null
foreach ($scheduleFile in $scheduleFiles) {
Copy-Item -LiteralPath $scheduleFile.FullName -Destination $scheduleRelease -Force
}
Write-Host "Built schedule release files: $scheduleRelease"
}
if ($SchedulesOnly) {
Write-ScheduleRelease
return
}
if ([string]::IsNullOrWhiteSpace($GameDir)) {
$developmentConfigPath = Join-Path $goRoot 'config.json'
if (-not [IO.File]::Exists($developmentConfigPath)) {
@@ -89,7 +117,8 @@ try {
$clientPlugins = @(
@{ Name = 'LocalIdentity'; Project = Join-Path $root 'plugins\LocalIdentity\LocalIdentity.csproj'; Output = Join-Path $root 'plugins\LocalIdentity\bin\Release\netstandard2.1\BD2LocalIdentity.dll'; FileName = 'BD2LocalIdentity.dll' },
@{ Name = 'LoginUI'; Project = Join-Path $root 'plugins\LoginUI\LoginUI.csproj'; Output = Join-Path $root 'plugins\LoginUI\bin\Release\netstandard2.1\BD2LoginUI.dll'; FileName = 'BD2LoginUI.dll' }
@{ Name = 'LoginUI'; Project = Join-Path $root 'plugins\LoginUI\LoginUI.csproj'; Output = Join-Path $root 'plugins\LoginUI\bin\Release\netstandard2.1\BD2LoginUI.dll'; FileName = 'BD2LoginUI.dll' },
@{ Name = 'CashShop'; Project = Join-Path $root 'plugins\CashShop\CashShop.csproj'; Output = Join-Path $root 'plugins\CashShop\bin\Release\netstandard2.1\BD2CashShop.dll'; FileName = 'BD2CashShop.dll' }
)
foreach ($plugin in $clientPlugins) {
$pluginBuildArgs = @('build', $plugin.Project, '-c', 'Release', "-p:GameDir=$GameDir", '--nologo')
@@ -108,6 +137,11 @@ foreach ($plugin in $clientPlugins) {
}
Copy-Item -LiteralPath (Join-Path $goRoot 'seed') -Destination $serverGoDir -Recurse -Force
$serverScheduleDirectory = Join-Path $serverPackage 'schedules'
New-Item -ItemType Directory -Force -Path $serverScheduleDirectory | Out-Null
foreach ($scheduleFile in $scheduleFiles) {
Copy-Item -LiteralPath $scheduleFile.FullName -Destination $serverScheduleDirectory -Force
}
Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $serverPackage 'versions.json') -Force
Copy-Item -LiteralPath $authenticationConfig -Destination (Join-Path $serverPackage 'authentication.json') -Force
Copy-Item -LiteralPath $resourceConfig -Destination (Join-Path $serverPackage 'resources.json') -Force
+75
View File
@@ -0,0 +1,75 @@
# BD2 Client Studio
BD2 Client Studio 是独立的 Windows / macOS 客户端配置工具,不是服务端,也不包含玩家存档、`auth.db`、OAuth secret 或 master key。Linux 可以运行服务端,但不提供游戏客户端工具。
Windows 使用 Wails 和系统 WebView2 Runtime 创建桌面窗口,macOS 使用 Cocoa 和系统 WKWebView。窗口由客户端进程管理;关闭窗口会退出整个工具,退出工具也会关闭窗口。Windows 首次使用前应确保已安装 Microsoft Edge WebView2 Runtime:<https://developer.microsoft.com/microsoft-edge/webview2/>。
当前本机发布脚本只生成 Windows 包。macOS 源码保留,后续找到测试者或公开发布时,将使用 GitHub Actions 的 macOS runner 进行原生构建、测试和 `.app` 打包;目前未完成 macOS 实机验证。
启动后会打开配置工作台:
1. 选择 Brown Dust II 安装目录。Windows 选择包含 `BrownDust II.exe` 的目录;macOS 可选择 `BrownDust II.app` 或它的父目录。
2. 填写要进入的服务器 origin,例如 `https://play.example.com` 或 `http://127.0.0.1:8080`。
3. 选择 CDN:
- **官方 CDN**:玩家直接从版本锁定的官方节点下载资源,适合朋友通过 FRP 联机。
- **本机已下载资源**:选择同时包含 `ServerData` 和 `GameData` 的 CDN 根目录;LI 插件会在游戏进程内启动仅监听 loopback 的只读资源服务。
- **服主资源**:通过服务器 `PUT /client/resources` 取得版本锁定的资源 URL;服主自建和反代对玩家客户端是同一种模式。
4. 点击“应用客户端补丁”和“安装 / 更新插件”。
工作台的“连接设置”提供“代理 → 设置代理”入口。默认直接连接,不自动读取系统或终端环境中的代理。需要代理时,选择“手动 HTTP 代理”,填写例如 `http://127.0.0.1:12451` 的地址,点击“保存连接设置”,再通过工作台启动游戏。不要填写 Markdown 链接或 SOCKS 地址。
代理地址必须包含端口,不包含账号密码、路径或查询参数。HTTP 代理可通过 CONNECT 转发 HTTPS 请求,证书仍正常验证;本机 loopback 地址始终直连。配置同时用于客户端资源策略请求和新启动游戏的 HTTP 请求。修改代理后,需要正常退出游戏再重新启动;浏览器 OAuth 登录仍使用浏览器自身的网络设置。
客户端配置写入:
```text
Windows: <游戏目录>/BepInEx/config/bd2.client.json
macOS: <BrownDust II.app 的父目录>/BepInEx/config/bd2.client.json
```
macOS 同时兼容把 BepInEx 放在 `BrownDust II.app/Contents/BepInEx` 的布局;若两个位置都存在,优先使用 App Bundle 外的标准 BepInEx 目录。
工具只写入:
```json
{
"schema_version": 2,
"server_origin": "https://play.example.com",
"cdn_mode": "official",
"proxy_url": "http://127.0.0.1:12451"
}
```
`proxy_url` 可选,省略或留空表示直接连接。
`bd2client.exe` 启动时严格读取同目录的 `versions.json`。`game_version` 是支持的官方游戏版本;`client_version` 与 `server_version` 是分别演进的软件发布版本,格式为 `游戏版本+client.X.Y.Z` 与 `游戏版本+server.X.Y.Z`。资源仍由 `bundle_version` 和 `game_data_version` 锁定:
- 从所选客户端的 `BrownDust II_Data/globalgamemanagers` 读取 Unity `bundleVersion`,必须精确等于 `game_version`。不能使用 `BrownDust II.exe` 的 Windows `FileVersion`,该值是 Unity 引擎版本。
- 本机资源必须包含当前 `bundle_version` 对应的 `catalog_alpha.json` / `.hash`,以及当前 `game_data_version` 对应的 `common-dbdata.info` / `.bin`。
- 服主资源接口返回的 Bundle 和 GameData 版本必须分别精确匹配,不能互相替代。
- `versions.json` 缺失、字段错误、出现未知字段或尾随 JSON 时,客户端工具拒绝启动。
本机资源模式会额外保存玩家自己选择的目录:
```json
{
"schema_version": 2,
"server_origin": "http://127.0.0.1:8080",
"cdn_mode": "local",
"local_resource_directory": "E:\\bd2\\dl"
}
```
本机路径不会发送给服务器。插件不会直接依赖各加载链对 `file://` 的兼容性,而是在游戏进程内通过随机 loopback 端口提供 `GET`、`HEAD` 和单段 Range;该端口不会暴露给局域网。
补丁只把 Intro 中的地址替换成固定的本地占位地址,并保留 `resources.assets.bak`。真实服务器地址由 `BD2LocalIdentity.dll` 从 `bd2.client.json` 读取,因此不受 Unity 内置 27-byte URL 替换限制。
选择服主资源时,服务器必须在同一版本的 `resources.json` 中启用统一的 `server` 模式;客户端会使用严格的 `PUT /client/resources` 读取版本锁定 URL。客户端不会接受模式不匹配、版本不匹配、非 HTTPS(loopback 除外)或带凭据的资源地址。
界面语言按系统时区自动选择:中国大陆、香港、澳门和台湾地区使用简体中文,其他地区使用英文。诊断日志始终使用英文;Windows 保存在 `bd2client.exe` 同目录的 `logs/bd2client.log`,macOS 保存在 `~/Library/Logs/BD2 Client Studio/bd2client.log`。单文件最大 `2 MiB`,并只保留一个 `bd2client.log.1` 备份;日志不会写入 UI session、OAuth token 或 secret。
工具需要玩家事先安装 BepInEx。服务端不再安装客户端插件,也不需要知道玩家的 Windows 游戏目录。关闭游戏后再执行补丁或插件安装。
补丁与插件安装均可重复安全执行。Intro 已经指向当前占位地址时不会再次改写 `resources.assets`;两个已安装 DLL 与发布包内容一致时不会再次复制,并会明确提示已经完成或已经是最新版本。连接配置仍会按当前界面设置进行原子保存。
工作台右上角的“启动游戏”会再次验证所选客户端及精确版本,然后通过 Windows 进程启动或 macOS Launch Services 打开游戏。macOS 不会打开 Terminal。
+48
View File
@@ -0,0 +1,48 @@
# 项目排期文件
服务端从发布目录中的固定 `schedules/` 目录读取全部 `.bd2schedule` 文件,完整校验后合并下发排期。文件采用项目自定义二进制格式;没有 JSON 排期入口或服主自定义配置功能。项目维护者可在游戏版本不变时单独发布排期文件,覆盖相应文件后重启服务端加载。文件中的游戏和 GameData 版本标签表示协议、静态设计兼容性,排期 `revision` 独立表示日历修订。
## 二进制格式版本 1
整数使用小端编码。文件头固定 46 字节:
| 偏移 | 长度 | 内容 |
| --- | --- | --- |
| 0 | 8 | 魔数 `BD2SCH` 后接两个零字节 |
| 8 | 2 | uint16 格式版本,当前为 1 |
| 10 | 4 | uint32 payload 字节数 |
| 14 | 32 | payload 的 SHA-256 原始摘要 |
| 46 | payload 长度 | 按固定顺序编码的记录 |
payload 首先编码 revision、game version、GameData version 三个字符串,再依次编码事件列表、抽卡列表、StepUp 列表、可选常规内容日历、可选魔物追踪日历、商品列表、活动 Hub 列表、小游戏 Hub 列表。
字符串编码为 uint32 字节长度及 UTF-8 字节;列表编码为 uint32 记录数量及逐条记录;可选记录先写 0/1 存在标记;布尔值只允许单字节 0 或 1;身份、数量等整数字段编码为 uint64。每类记录采用显式固定字段顺序,权威定义为 `go/internal/server/calendar/records.go`,不使用反射或嵌入 JSON。单文件最多 8 MiB,单字符串最多 64 KiB,记录总量最多 100000;错误魔数、版本、摘要、长度、布尔值及尾随内容都拒绝加载。
记录内时间采用 RFC3339 UTC 字符串并保留毫秒,以保留跨年份和永久窗口语义。永久事件起点 `1969-12-31T15:00:00.000Z` 对应协议有符号 int64 的 `-32400000` 毫秒。它不是无符号巨大日期。二进制校验用于发现文件损坏,不代表密码签名。
项目维护者在代码中构建具名 `calendar.Manifest` 并调用 `calendar.MarshalBinary` 生成发行文件;读取使用 `calendar.UnmarshalBinary`。项目不提供从公开 JSON 输入生成排期的服主管理命令。发布时保留所有要下发的排期文件;运行时不从版本化 seed 或抓包补齐缺失数据。
## 初始数据来源
当前 `local-20261005-1` 保留已有时间、编号和协议标记,没有推算后续官方日程:
| 文件 | 初始记录 | 原始来源 |
| --- | --- | --- |
| `gacha.bd2schedule` | 普通抽卡 11,StepUp 2 | 已迁移删除的 `go/seed/v2_35_10/gacha_schedule.json` |
| `regular.bd2schedule` | 常规内容 9,regular 标记 5 | 已迁移删除的 `go/seed/v2_35_10/schedule.json` |
| `monster_hunt.bd2schedule` | 魔物追踪窗口 1,历史 78 | 原 `go/seed/v2_35_10/readonly.json` 的 `/MonsterHuntScheduleInfo` |
| `cash_shop.bd2schedule` | 商品窗口 37 | 原 readonly seed 的 `/CashShopInfo` 商品行 |
| `event_hubs.bd2schedule` | 活动 Hub 2,小游戏 slot 6 | 原 readonly seed 的 `/EventHubInfo`、`/MiniGameHubInfo` |
| `events.bd2schedule` | 事件排期 54 | `data/capture/2.35.10/20261001-004259/bodies/000004_response_EventScheduleInfoResponse.pb` |
事件文件将以上同版本响应的既有身份和起止时间维护为项目日历;捕获的 `is_active` 不复制,由服务端根据当前时间重新计算。来源仅用于审计,运行时不读取抓包。UID 为 0 的多条任务以类型和设计组 ID 区分,不制造公开 UID。任务进度和领奖也使用对应组的独立本地身份。
Hub 的 UID 是 Hub 身份;slot 内的 UID 按内容类型引用事件、魔物追踪赛季等不同领域。例如 MonsterHunt slot 引用赛季 77/78。旧 Hub 74 的原始时间和子身份作为归档保留,当前捕获事件列表主要对应 Hub 75;不据此虚构已结束 Hub 的子事件窗口。Hub 的 play_end 与 end 独立保留,setting 可以引用多个 UID。
事件类型 8 的 ID 引用 `PackTable.Id`,依据当前客户端 `PackInfo.IsProgressUseScheduleEventPack` 和 `IsFinishEventPackSchedule`;它与 Hub 的 `PackEventHubTable.id`、`packId` 分属不同身份。小游戏 Hub 的 progress_type 表示普通、首次开放或复刻状态,不等于静态小游戏的 eventClearType。
商品每日、每周、每月重置时间由运行时根据当前时间生成。`SkyWayScheduleInfo` 的 weekday/bonus 规则仍属于静态 seed,`PassInfo` 和 `SeasonRewardInfo` 属于玩家或奖励域。
## 更新和验证
排期更新由项目维护,相关文件的 revision 随数据修订更新。仅调整时间无需修改游戏版本;引用不同静态设计时必须更新相应版本标签并校验实际 GameData。加载同时校验跨文件重复身份、时间窗口和可玩设计引用,全部通过才下发。事件类型 0、1、4、5、7、8、9、10、11、12、13、17、19 和 20 至 25 校验对应 GameData;类型 3、6、14、15、16、18 当前仅校验协议范围和格式。魔物追踪可玩窗口校验实际 hunt 设计,归档历史仅校验显示记录格式;小游戏 Hub 的 progress_type 校验状态格式。公共日历保留未来、当前和归档记录,玩法入口及进行中标记根据时间和玩家状态限制。
+7
View File
@@ -5,5 +5,12 @@
},
"gacha": {
"include_collaboration_ur_weapons": false
},
"purchases": {
"diamond_recharge": {
"currency": "free",
"gold_per_paid_diamond": 1000,
"diamonds_per_paid_diamond": 1
}
}
}
+2
View File
@@ -88,6 +88,7 @@ func developmentRunOptions(args []string) ([]string, clientRunOptions, error) {
logExecutablePath: filepath.Join(root, "data", "bd2client-dev"),
localIdentityPlugin: filepath.Join(root, "plugins", "LocalIdentity", "bin", "Release", "netstandard2.1", "BD2LocalIdentity.dll"),
loginUIPlugin: filepath.Join(root, "plugins", "LoginUI", "bin", "Release", "netstandard2.1", "BD2LoginUI.dll"),
cashShopPlugin: filepath.Join(root, "plugins", "CashShop", "bin", "Release", "netstandard2.1", "BD2CashShop.dll"),
}, nil
}
@@ -176,6 +177,7 @@ func buildDevelopmentPlugins(root, gameDir string) error {
projects := []string{
filepath.Join(root, "plugins", "LocalIdentity", "LocalIdentity.csproj"),
filepath.Join(root, "plugins", "LoginUI", "LoginUI.csproj"),
filepath.Join(root, "plugins", "CashShop", "CashShop.csproj"),
}
for _, project := range projects {
fmt.Fprintf(os.Stderr, "bd2client: preparing development plugin %s; first-time game source generation may take several minutes\n", filepath.Base(project))
+1
View File
@@ -94,6 +94,7 @@ func TestDevelopmentRunOptionsUsesRepositoryFiles(t *testing.T) {
"log executable": options.logExecutablePath,
"local identity": options.localIdentityPlugin,
"login UI": options.loginUIPlugin,
"cash shop": options.cashShopPlugin,
} {
if !filepath.IsAbs(path) {
t.Errorf("%s path is not absolute: %q", name, path)
+1 -1
View File
@@ -20,7 +20,7 @@ func TestDevelopmentRunBuildsPluginsWithEmbeddedNames(t *testing.T) {
if len(args) != 0 {
t.Fatalf("unexpected runtime arguments: %v", args)
}
for _, path := range []string{options.localIdentityPlugin, options.loginUIPlugin} {
for _, path := range []string{options.localIdentityPlugin, options.loginUIPlugin, options.cashShopPlugin} {
if !clientDevelopmentFile(path) {
t.Fatalf("development plugin output missing: %s", path)
}
+2
View File
@@ -14,6 +14,7 @@ type clientRunOptions struct {
logExecutablePath string
localIdentityPlugin string
loginUIPlugin string
cashShopPlugin string
}
func main() {
@@ -80,6 +81,7 @@ func runClient(args []string, options clientRunOptions) error {
Versions: versions,
LocalIdentityPlugin: options.localIdentityPlugin,
LoginUIPlugin: options.loginUIPlugin,
CashShopPlugin: options.cashShopPlugin,
}); err != nil {
logger.Error("bd2client stopped with an error", "error", err)
return err
+311 -13
View File
@@ -21,7 +21,15 @@ import (
"bd2server/internal/server/authconfig"
"bd2server/internal/server/battle"
"bd2server/internal/server/bootstrap"
"bd2server/internal/server/calendar"
"bd2server/internal/server/commerce"
"bd2server/internal/server/deck"
"bd2server/internal/server/eventactions"
"bd2server/internal/server/eventexchange"
"bd2server/internal/server/eventgames"
"bd2server/internal/server/eventplay"
"bd2server/internal/server/events"
"bd2server/internal/server/eventtasks"
"bd2server/internal/server/feature"
"bd2server/internal/server/gacha"
"bd2server/internal/server/gameconfig"
@@ -37,7 +45,6 @@ import (
"bd2server/internal/server/readonly"
"bd2server/internal/server/resourcefetch"
"bd2server/internal/server/resourcepolicy"
"bd2server/internal/server/schedule"
"bd2server/internal/server/session"
"bd2server/internal/server/stateio"
"bd2server/internal/server/transport"
@@ -98,14 +105,12 @@ func serve(args []string) (serveErr error) {
gameDataOrigin := fs.String("game-data-origin", resourcepolicy.OfficialGameDataURL, "official GameData repair source override for development")
accountSeed := fs.String("account-seed", "", "versioned local account seed")
playerSeed := fs.String("player-seed", "", "versioned starter inventory and characters")
readonlySeed := fs.String("readonly-seed", "", "versioned server schedules and optional feature defaults")
readonlySeed := fs.String("readonly-seed", "", "versioned static protocol defaults")
mailSeed := fs.String("mail-seed", "", "versioned starter mailbox")
mailGrantSpool := fs.String("mail-grant-spool", "", "optional local JSON spool for idempotent dynamic system mail")
stateFile := fs.String("state", "", "account SQLite database override")
deckSeed := fs.String("deck-seed", "", "versioned starter deck")
worldSeed := fs.String("world-seed", "", "versioned starter world")
gachaScheduleSeed := fs.String("gacha-schedule-seed", "", "versioned dynamic gacha schedule")
seasonScheduleSeed := fs.String("schedule-seed", "", "versioned server content calendar")
devToolsConfig := fs.String("dev-tools-config", "", "development-tool settings JSON (defaults to DATA_DIR/dev-tools.json)")
if err := fs.Parse(args); err != nil {
return err
@@ -113,6 +118,11 @@ func serve(args []string) (serveErr error) {
if err := configureLogging(os.Stderr, *logLevel, *logColor); err != nil {
return err
}
defer func() {
if err := gamedata.CloseDatabaseCache(); err != nil {
serveErr = errors.Join(serveErr, fmt.Errorf("close GameData query cache: %w", err))
}
}()
var versions versionconfig.Config
var err error
if *versionConfigPath == "" {
@@ -134,6 +144,15 @@ func serve(args []string) (serveErr error) {
}
}
versionconfig.Use(versions)
calendarDirectory := versions.Resolve("schedules")
calendars, err := calendar.LoadDirectory(calendarDirectory, versions.GameVersion, versions.GameDataVersion)
if err != nil {
return fmt.Errorf("load project calendars: %w", err)
}
if calendars.RegularService == nil || calendars.MonsterHunt == nil || len(calendars.MonsterHunt.Seasons) == 0 {
return errors.New("project calendars require regular content and monster hunt schedules")
}
slog.Info("project calendars loaded", "directory", calendarDirectory, "revisions", calendars.Revisions, "events", len(calendars.Events))
if *gameConfigPath == "" {
*gameConfigPath, err = gameconfig.BesideExecutable()
if err != nil {
@@ -191,8 +210,7 @@ func serve(args []string) (serveErr error) {
seedRoot := versions.Resolve(versions.SeedDirectory)
for target, name := range map[*string]string{
accountSeed: "login_user.json", playerSeed: "starter_player.json", readonlySeed: "readonly.json",
mailSeed: "mail.json", deckSeed: "decks.json", worldSeed: "world.json", gachaScheduleSeed: "gacha_schedule.json",
seasonScheduleSeed: "schedule.json",
mailSeed: "mail.json", deckSeed: "decks.json", worldSeed: "world.json",
} {
if *target == "" {
*target = filepath.Join(seedRoot, name)
@@ -222,6 +240,9 @@ func serve(args []string) (serveErr error) {
if downloaded {
slog.Info("repaired GameData from official CDN", "archive", verifiedGameData.ArchivePath, "entries", verifiedGameData.EntryCount)
}
if err := calendars.ValidateDesign(gameData, *gameDataVersion); err != nil {
return fmt.Errorf("validate project calendar GameData references: %w", err)
}
login, err := account.Load(filepath.Clean(*accountSeed))
if err != nil {
return fmt.Errorf("load local account seed: %w", err)
@@ -233,10 +254,7 @@ func serve(args []string) (serveErr error) {
if login.Version != versions.GameVersion || starter.Version != versions.GameVersion {
return fmt.Errorf("game version %s requires matching account and player seeds (got %s and %s)", versions.GameVersion, login.Version, starter.Version)
}
gachaSchedule, err := gacha.LoadScheduleSeed(filepath.Clean(*gachaScheduleSeed), versions.GameVersion)
if err != nil {
return fmt.Errorf("load gacha schedule: %w", err)
}
gachaSchedule := calendars.GachaSeed
var scheduleGroupIDs, stepUpGroupIDs []uint64
for _, window := range gachaSchedule.Schedules {
scheduleGroupIDs = append(scheduleGroupIDs, window.GroupID)
@@ -311,10 +329,11 @@ func serve(args []string) (serveErr error) {
if err != nil {
return fmt.Errorf("load readonly server configuration: %w", err)
}
seasonSchedule, err := schedule.Load(filepath.Clean(*seasonScheduleSeed))
serverConfig, err = calendars.ApplyReadonly(serverConfig)
if err != nil {
return fmt.Errorf("load server content calendar: %w", err)
return fmt.Errorf("encode project calendars: %w", err)
}
seasonSchedule := calendars.RegularService
mailbox, err := mail.Load(filepath.Clean(*mailSeed))
if err != nil {
return fmt.Errorf("load starter mailbox: %w", err)
@@ -659,7 +678,266 @@ func serve(args []string) (serveErr error) {
if err := login.AttachHuntingAP(huntingService); err != nil {
return fmt.Errorf("attach persisted hunting AP: %w", err)
}
huntingAPDesign, err := gamedata.LoadHuntingAPDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load hunting AP reset: %w", err)
}
if err = huntingService.AttachAPRefresh(huntingAPDesign); err != nil {
return err
}
battleService.AttachHunting(huntingService)
huntingService.AttachEligibility(worldService.HuntingEligibility)
if err := worldService.AttachHuntingGround(huntingService); err != nil {
return err
}
eventRegistry := events.NewRegistry()
if err := eventRegistry.Replace(calendars.Events); err != nil {
return err
}
rewardGraph, err := gamedata.LoadRewardGraph(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load event reward graph: %w", err)
}
rewardEquipment, err := gamedata.LoadRewardEquipmentCatalog(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load reward equipment: %w", err)
}
rewardCostumes, err := gamedata.LoadRewardCostumeCatalog(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load reward costumes: %w", err)
}
initialEventCurrency := map[uint64]uint64{}
for itemType, field := range events.AdditionalCurrencyFields {
value, _, readErr := wire.Varint(login.UserInfo, field)
if readErr != nil {
return readErr
}
initialEventCurrency[itemType] = value
}
eventEconomy, err := events.NewEconomy(gameplayStore, ownedItems, wallet, collection, ownedEquipment, rewardCostumes, rewardEquipment, rewardGraph, initialEventCurrency)
if err != nil {
return fmt.Errorf("load event economy: %w", err)
}
eventEconomy.AttachHuntingAP(huntingService)
if err = worldService.AttachResearchRuntime(gameData, *gameDataVersion, eventEconomy); err != nil {
return fmt.Errorf("attach field research: %w", err)
}
prestigeSkins, err := gamedata.LoadPrestigeSkins(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load reward prestige skins: %w", err)
}
eventEconomy.AttachPrestigeSkins(prestigeSkins)
ownedEventItems, err := gamedata.LoadOwnedEventItemDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load event inventory design: %w", err)
}
eventEconomy.AttachOwnedItemDesign(ownedEventItems)
eventAPCaps, eventAPReset, err := gamedata.LoadEventAPDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load event AP reset: %w", err)
}
if err = eventEconomy.AttachAPRefresh(eventAPCaps, eventAPReset); err != nil {
return err
}
if err = login.AttachAdditionalCurrencies(eventEconomy); err != nil {
return err
}
huntingService.AttachRewards(func(identity string, rewards []gamedata.Reward) ([]byte, error) {
return eventEconomy.Apply(identity, nil, rewards)
})
cashDesign, err := gamedata.LoadCashCatalog(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load cash products: %w", err)
}
cashCatalog, err := commerce.NewCatalog(versions.GameVersion, cashDesign, gameRules.Purchases)
if err != nil {
return fmt.Errorf("configure cash products: %w", err)
}
cashEntitlementDesign, err := gamedata.LoadCashEntitlementDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load cash entitlement design: %w", err)
}
cashRewards, err := gamedata.LoadCashRewardResolver(gameData, *gameDataVersion, rewardGraph)
if err != nil {
return fmt.Errorf("load cash product rewards: %w", err)
}
cashEconomy, err := commerce.NewEntitlementEconomy(gameplayStore, eventEconomy, cashRewards, ownedItems, cashEntitlementDesign)
if err != nil {
return fmt.Errorf("load cash entitlements: %w", err)
}
cashEconomy.SetClock(time.Now, eventAPReset.ResetSeconds-9*3600)
cashService, err := commerce.NewService(cashCatalog, gameplayStore, cashEconomy)
if err != nil {
return fmt.Errorf("load cash purchase state: %w", err)
}
clearPackageDesign, err := gamedata.LoadClearPackageCatalog(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load clear-package rewards: %w", err)
}
clearPackages, err := commerce.NewClearPackages(gameplayStore, clearPackageDesign, cashEconomy, ownedItems)
if err != nil {
return fmt.Errorf("load clear-package claims: %w", err)
}
clearPackages.AttachProgress(worldService.CashPackagePackCleared, nil)
cashService.SetClock(time.Now, eventAPReset.ResetSeconds-9*3600)
if err := cashService.AttachPackageRules(cashDesign.Packages); err != nil {
return fmt.Errorf("attach cash package progression: %w", err)
}
if err := cashService.AttachShopSeed(serverConfig); err != nil {
return fmt.Errorf("attach cash product availability: %w", err)
}
cashService.AttachLegacyCounts(gachaService)
cashService.AttachDelegate(func(key gamedata.CashProductKey, request []byte) ([]byte, bool, error) {
known := key.GroupID == infiniteGacha.ProductGroupID && key.ProductID == infiniteGacha.ProductID && key.SaleGroup == infiniteGacha.SaleGroup
for _, group := range regularGacha.Groups() {
if key.GroupID == group.CashProductGroupID && key.ProductID == group.CashProductID && key.SaleGroup == group.CashSalesGroup {
known = true
break
}
}
if !known {
return nil, false, nil
}
_, response, handled, err := gachaService.Handle("/CashShopBuy", request)
if err != nil || !handled {
return nil, handled, err
}
bundle, _, err := wire.Bytes(response, 1)
return bundle, true, err
})
if err := login.AttachPurchaseCounts(cashService); err != nil {
return fmt.Errorf("attach cash purchase counts: %w", err)
}
eventTasksDesign, err := gamedata.LoadEventTasksDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load event tasks design: %w", err)
}
eventTasksService, err := eventtasks.Open(gameplayStore, eventTasksDesign, eventRegistry, eventEconomy)
if err != nil {
return fmt.Errorf("load event tasks state: %w", err)
}
newbieStep, _, err := wire.Varint(login.UserInfo, 39)
if err != nil {
return err
}
if err = eventTasksService.SetNewbieStep(newbieStep); err != nil {
return err
}
if err = login.AttachNewbieStep(eventTasksService); err != nil {
return err
}
eventTasksService.AttachCashAuthorization(func(passID, buyType uint64) bool {
for _, buy := range eventTasksDesign.PassBuys[passID] {
if buy.Type == buyType && buy.CashID != 0 {
return cashService.ConsumeEntitlement(gamedata.CashProductKey{GroupID: buy.CashGroup, ProductID: buy.CashID, SaleGroup: buy.CashSales})
}
}
return false
})
eventTasksService.AttachAttendancePremium(func(ticket uint64) bool {
for _, item := range ownedItems.All() {
if item.Type == 19 && item.ID == ticket && item.Count > 0 && (item.ExpiryTime == 0 || item.ExpiryTime > uint64(time.Now().UnixMilli())) {
return true
}
}
return false
})
loginPassDesign, err := gamedata.LoadLoginPassCatalog(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load login-pass rewards: %w", err)
}
loginPasses, err := commerce.NewLoginPasses(gameplayStore, loginPassDesign, cashEconomy, ownedItems, func(group uint64) bool {
for _, pack := range cashDesign.Packages {
if pack.PackageType == 7 && pack.ID == group && cashService.IsAvailable(gamedata.CashProductKey{GroupID: pack.GroupID, ProductID: pack.ID, SaleGroup: pack.SaleGroup}) {
return true
}
}
return false
})
if err != nil {
return fmt.Errorf("load login-pass progress: %w", err)
}
loginPasses.SetClock(time.Now, eventAPReset.ResetSeconds-9*3600)
eventTasksService.AttachUnlockResolver(missionUnlocked)
missionService.AttachEventHandler(eventTasksService)
if err = missionService.RecordLogin(missionUnlocked); err != nil {
return err
}
eventGamesService, err := eventgames.Open(gameplayStore, gameData, *gameDataVersion, eventRegistry, eventEconomy)
if err != nil {
return fmt.Errorf("load event games state: %w", err)
}
eventExchangeDesign, err := gamedata.LoadEventExchangeCatalog(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load event exchange design: %w", err)
}
eventExchangeService, err := eventexchange.Open(gameplayStore, eventExchangeDesign, eventRegistry, eventEconomy)
if err != nil {
return fmt.Errorf("load event exchange state: %w", err)
}
boxService, err := events.OpenBoxes(gameplayStore, ownedItems, eventEconomy)
if err != nil {
return fmt.Errorf("load random box state: %w", err)
}
eventPlayService, err := eventplay.Open(gameplayStore, gameData, *gameDataVersion, eventRegistry, eventEconomy)
if err != nil {
return fmt.Errorf("load event play state: %w", err)
}
eventBattleChallenges, err := gamedata.LoadEventBattleChallenges(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load event battle challenges: %w", err)
}
eventPlayService.AttachBattleChallenges(eventBattleChallenges)
eventPlayService.AttachHubCalendars(serverConfig)
battleService.AttachEventBattle(eventPlayService)
eventActionsDesign, err := gamedata.LoadEventActionsDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load event action design: %w", err)
}
eventActionsService, err := eventactions.Open(gameplayStore, eventActionsDesign, eventRegistry, eventEconomy)
if err != nil {
return fmt.Errorf("load event action state: %w", err)
}
eventActionsService.AttachFriendshipLevel(func(id uint64) uint64 {
for _, entry := range collection.FriendshipEntries() {
if entry.State != nil && entry.State.CostumeID == id {
return entry.State.Level
}
}
return 0
})
eventActionsService.AttachOwnedCharacter(func(index, id uint64) bool {
c, ok := worldService.CharacterService().Find(index)
return ok && c.ID == id
})
eventActionsService.AttachChargeInfo(func() ([]byte, error) {
rows, err := eventEconomy.ChargeInfo()
if err != nil {
return nil, err
}
huntingRows, err := huntingService.APChargeInfo()
if err != nil {
return nil, err
}
return append(rows, huntingRows...), nil
})
eventActionsService.AttachProgress(func(condition, sub, count uint64) error {
return missionService.RecordEvent(condition, sub, count, missionUnlocked)
})
eventPlayService.AttachProgress(func(condition, sub, count uint64) error {
return missionService.RecordEvent(condition, sub, count, missionUnlocked)
})
eventTasksService.AttachAssociatedMissionGroup(func(schedule events.Schedule) uint64 {
if group := eventActionsService.AssociatedMissionGroup(schedule); group != 0 {
return group
}
group, err := eventPlayService.AssociatedMissionGroup(schedule)
if err != nil {
slog.Error("event mission design unavailable", "event_uid", schedule.UID, "event_id", schedule.ID, "error", err)
}
return group
})
battleService.AttachEventBattle(eventActionsService)
battleService.AttachCurrentDifficulty(worldService.CurrentQuestDifficulty)
if err := worldService.AttachBattleActive(battleService.Active); err != nil {
return fmt.Errorf("attach world battle guard: %w", err)
@@ -676,6 +954,9 @@ func serve(args []string) (serveErr error) {
return fmt.Errorf("attach monster hunt preset slots: %w", err)
}
battleService.AttachMonsterHunt(monsterHuntService)
monsterHuntService.AttachRewards(func(identity string, rewards []gamedata.Reward) ([]byte, error) {
return eventEconomy.Apply(identity, nil, rewards)
})
recruitDesign, err := gamedata.LoadRecruitDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load recruitment GameData: %w", err)
@@ -750,7 +1031,19 @@ func serve(args []string) (serveErr error) {
if err := achievementObserver.SyncRecordedHistory(); err != nil {
return fmt.Errorf("restore recorded achievement history: %w", err)
}
eventTasksService.AttachGameplayProvider(worldService.GameplayAchievementProvider(achievementCounterDesign, achievementGrades))
game, err := session.NewServerWithProgress(login, progressState,
cashService,
clearPackages,
commerce.PackInfoHandler{World: worldService, Claims: clearPackages},
commerce.AttendanceHandler{Events: eventTasksService, Economy: cashEconomy, LoginPasses: loginPasses, Store: gameplayStore},
eventRegistry,
eventGamesService,
eventExchangeService,
boxService,
eventPlayService,
eventActionsService,
events.SkinHandler{Economy: eventEconomy},
battleService,
huntingService,
monsterHuntService,
@@ -774,6 +1067,7 @@ func serve(args []string) (serveErr error) {
gachaService,
achievementCounters,
missionService,
eventTasksService,
pictorialService,
seasonSchedule,
readonly.Service{Seed: serverConfig},
@@ -785,6 +1079,9 @@ func serve(args []string) (serveErr error) {
if err := game.AttachResponseObserver(achievementObserver); err != nil {
return fmt.Errorf("attach achievement progress notifications: %w", err)
}
if err := game.AttachResponseObserver(eventTasksService); err != nil {
return err
}
if authService != nil {
if err := game.AttachLoginAuthenticator(authService); err != nil {
return err
@@ -844,7 +1141,8 @@ func serve(args []string) (serveErr error) {
instanceID := hex.EncodeToString(instanceBytes)
handler := transport.HTTP{
Dispatcher: dispatcher, Raw: game, Authentication: authentication,
AuthenticationHandler: authHandler, ResourcePolicy: publicResources, Availability: availability, InstanceID: instanceID,
AuthenticationHandler: authHandler, ResourcePolicy: publicResources,
CommerceManifest: func() any { return cashCatalog.Manifest() }, Availability: availability, InstanceID: instanceID,
}.Handler()
server := &http.Server{
Addr: *listen,
+16 -9
View File
@@ -9,15 +9,14 @@ import (
"bd2server/internal/server/account"
"bd2server/internal/server/authconfig"
"bd2server/internal/server/calendar"
"bd2server/internal/server/deck"
"bd2server/internal/server/gacha"
"bd2server/internal/server/gameconfig"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
"bd2server/internal/server/readonly"
"bd2server/internal/server/resourcepolicy"
calendarschedule "bd2server/internal/server/schedule"
"bd2server/internal/server/versionconfig"
)
@@ -105,16 +104,24 @@ func preflight(args []string) error {
if _, err := deck.LoadSeed(filepath.Join(seedRoot, "decks.json")); err != nil {
return err
}
if _, err := readonly.Load(filepath.Join(seedRoot, "readonly.json")); err != nil {
return err
}
if _, err := calendarschedule.Load(filepath.Join(seedRoot, "schedule.json")); err != nil {
return fmt.Errorf("load server content calendar: %w", err)
}
schedule, err := gacha.LoadScheduleSeed(filepath.Join(seedRoot, "gacha_schedule.json"), versions.GameVersion)
defaults, err := readonly.Load(filepath.Join(seedRoot, "readonly.json"))
if err != nil {
return err
}
calendars, err := calendar.LoadDirectory(versions.Resolve("schedules"), versions.GameVersion, versions.GameDataVersion)
if err != nil {
return err
}
if calendars.RegularService == nil || calendars.MonsterHunt == nil || len(calendars.MonsterHunt.Seasons) == 0 {
return fmt.Errorf("project calendars require regular content and monster hunt schedules")
}
if _, err := calendars.ApplyReadonly(defaults); err != nil {
return err
}
if err := calendars.ValidateDesign(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight project calendar GameData references: %w", err)
}
schedule := calendars.GachaSeed
var groups, steps []uint64
for _, window := range schedule.Schedules {
groups = append(groups, window.GroupID)
+4 -3
View File
@@ -30,6 +30,7 @@ type Options struct {
Versions clientconfig.ReleaseVersions
LocalIdentityPlugin string
LoginUIPlugin string
CashShopPlugin string
}
type Request struct {
@@ -266,7 +267,7 @@ func (s *Studio) Patch(input Request) (Response, error) {
func (s *Studio) Install(input Request) (Response, error) {
return s.perform("install plugins", func() (Response, error) {
result, err := clientsetup.InstallPlugins(input.GameDirectory, input.settings(), s.options.Versions, s.options.LocalIdentityPlugin, s.options.LoginUIPlugin)
result, err := clientsetup.InstallPlugins(input.GameDirectory, input.settings(), s.options.Versions, s.options.LocalIdentityPlugin, s.options.LoginUIPlugin, s.options.CashShopPlugin)
if err != nil {
return Response{}, err
}
@@ -274,7 +275,7 @@ func (s *Studio) Install(input Request) (Response, error) {
return Response{}, err
}
message := "BD2 client plugins installed or updated"
if !result.LocalIdentity.Changed && !result.LoginUI.Changed {
if !result.LocalIdentity.Changed && !result.LoginUI.Changed && !result.CashShop.Changed {
message = "BD2 client plugins are already up to date; no DLL was rewritten"
}
return success(message, result), nil
@@ -312,7 +313,7 @@ func (s *Studio) Launch(input Request) (Response, error) {
if !installation.SupportedOnHost() {
return Response{}, fmt.Errorf("cannot launch a %s game client from this operating system", installation.Kind)
}
for _, name := range []string{"BD2LocalIdentity.dll", "BD2LoginUI.dll"} {
for _, name := range []string{"BD2LocalIdentity.dll", "BD2LoginUI.dll", "BD2CashShop.dll"} {
info, err := os.Stat(filepath.Join(installation.Plugins, name))
if err != nil || !info.Mode().IsRegular() {
return Response{}, fmt.Errorf("install or update the client plugins before launching; %s is missing", name)
+1
View File
@@ -33,6 +33,7 @@ type ReleaseVersions struct {
LocalIdentity string `json:"local_identity"`
CaptureEnvironment string `json:"capture_environment"`
LoginUI string `json:"login_ui"`
CashShop string `json:"cash_shop"`
} `json:"plugins"`
}
+2 -1
View File
@@ -23,6 +23,7 @@ type Spec struct {
var (
LocalIdentity = Spec{fileName: "BD2LocalIdentity.dll"}
LoginUI = Spec{fileName: "BD2LoginUI.dll"}
CashShop = Spec{fileName: "BD2CashShop.dll"}
GameNames = Spec{fileName: "BD2.GameNames.dll"}
)
@@ -84,7 +85,7 @@ func Install(spec Spec, gameDir, source string) (Result, error) {
// Read both artifacts before touching the installation. The runtime table
// library is shipped beside every plugin, including explicit development paths.
var runtimeData []byte
if spec == LocalIdentity || spec == LoginUI {
if spec == LocalIdentity || spec == LoginUI || spec == CashShop {
runtimeSource := filepath.Join(filepath.Dir(source), GameNames.fileName)
runtimeData, err = os.ReadFile(runtimeSource)
if err != nil {
+4 -4
View File
@@ -8,7 +8,7 @@ import (
)
func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) {
for _, spec := range []Spec{LocalIdentity, LoginUI} {
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
t.Run(spec.FileName(), func(t *testing.T) {
gameDir := t.TempDir()
for path, data := range map[string][]byte{
@@ -41,7 +41,7 @@ func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) {
}
func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
for _, spec := range []Spec{LocalIdentity, LoginUI} {
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
t.Run(spec.FileName(), func(t *testing.T) {
gameDir := t.TempDir()
for path, data := range map[string][]byte{
@@ -140,7 +140,7 @@ func TestInstallKeepsPluginsSeparate(t *testing.T) {
t.Fatal(err)
}
}
for _, spec := range []Spec{LocalIdentity, LoginUI} {
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
source := filepath.Join(t.TempDir(), spec.FileName())
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
t.Fatal(err)
@@ -152,7 +152,7 @@ func TestInstallKeepsPluginsSeparate(t *testing.T) {
t.Fatal(err)
}
}
for _, spec := range []Spec{LocalIdentity, LoginUI} {
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
path := filepath.Join(gameDir, "BepInEx", "plugins", spec.FileName())
data, err := os.ReadFile(path)
if err != nil || string(data) != spec.FileName() {
+11 -1
View File
@@ -50,6 +50,7 @@ type ResourcePolicy struct {
type InstallResult struct {
LocalIdentity plugin.Result `json:"local_identity"`
LoginUI plugin.Result `json:"login_ui"`
CashShop plugin.Result `json:"cash_shop"`
}
func Inspect(gameDir string, versions clientconfig.ReleaseVersions) (GameStatus, error) {
@@ -117,6 +118,7 @@ func InstallPlugins(
versions clientconfig.ReleaseVersions,
localIdentitySource string,
loginUISource string,
cashShopSource string,
) (InstallResult, error) {
status, err := Inspect(gameDir, versions)
if err != nil {
@@ -136,6 +138,10 @@ func InstallPlugins(
if err != nil {
return InstallResult{}, err
}
cashSource, err := plugin.ResolvePackaged(plugin.CashShop, cashShopSource)
if err != nil {
return InstallResult{}, err
}
local, err := plugin.Install(plugin.LocalIdentity, gameDir, localSource)
if err != nil {
return InstallResult{}, err
@@ -144,7 +150,11 @@ func InstallPlugins(
if err != nil {
return InstallResult{}, err
}
return InstallResult{LocalIdentity: local, LoginUI: login}, nil
cash, err := plugin.Install(plugin.CashShop, gameDir, cashSource)
if err != nil {
return InstallResult{}, err
}
return InstallResult{LocalIdentity: local, LoginUI: login, CashShop: cash}, nil
}
func FetchResourcePolicy(ctx context.Context, client *http.Client, settings clientconfig.Settings, versions clientconfig.ReleaseVersions) (ResourcePolicy, error) {
+65 -15
View File
@@ -29,21 +29,45 @@ var (
// deliberately absent. ResponseFields contains the remaining top-level
// protobuf fields (such as client-notification state), never an HTTP envelope.
type LoginSeed struct {
Version string
PacketCode int
UserInfo []byte
ResponseFields []byte
currencies CurrencyProvider
purchaseCounts PurchaseCountProvider
presetSlots PresetSlotProvider
inventorySlots InventorySlotProvider
firstGacha FirstGachaProvider
friendshipAP FriendshipAPProvider
lastPlayedPack LastPlayedPackProvider
achievementExp AchievementExperienceProvider
levelReward LevelRewardProvider
huntingAP HuntingAPProvider
monsterHuntSlots PresetSlotProvider
Version string
PacketCode int
UserInfo []byte
ResponseFields []byte
currencies CurrencyProvider
purchaseCounts PurchaseCountProvider
presetSlots PresetSlotProvider
inventorySlots InventorySlotProvider
firstGacha FirstGachaProvider
friendshipAP FriendshipAPProvider
lastPlayedPack LastPlayedPackProvider
achievementExp AchievementExperienceProvider
levelReward LevelRewardProvider
huntingAP HuntingAPProvider
monsterHuntSlots PresetSlotProvider
additionalCurrencies interface {
AdditionalCurrencies() (map[int]uint64, error)
}
newbieStep interface{ NewbieStep() uint64 }
}
func (s *LoginSeed) AttachNewbieStep(provider interface{ NewbieStep() uint64 }) error {
if provider == nil {
return errors.New("account: missing newbie step provider")
}
s.newbieStep = provider
return nil
}
// AttachAdditionalCurrencies supplies balances owned by optional gameplay
// domains without altering the stable wallet representation.
func (s *LoginSeed) AttachAdditionalCurrencies(provider interface {
AdditionalCurrencies() (map[int]uint64, error)
}) error {
if provider == nil {
return errors.New("account: missing additional currency provider")
}
s.additionalCurrencies = provider
return nil
}
func (s *LoginSeed) AttachMonsterHuntSlots(provider PresetSlotProvider) error {
@@ -414,6 +438,32 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) {
return nil, fmt.Errorf("account: session key: %w", err)
}
user := append([]byte(nil), s.UserInfo...)
if s.newbieStep != nil {
step := s.newbieStep.NewbieStep()
if step > math.MaxInt32 {
return nil, errors.New("account: newbie step exceeds protocol range")
}
var err error
user, _, err = wire.ReplaceVarint(user, 39, step)
if err != nil {
return nil, err
}
}
if s.additionalCurrencies != nil {
values, err := s.additionalCurrencies.AdditionalCurrencies()
if err != nil {
return nil, err
}
for field, value := range values {
if value > math.MaxInt32 {
return nil, errors.New("account: additional currency exceeds protocol range")
}
user, _, err = wire.ReplaceVarint(user, field, value)
if err != nil {
return nil, err
}
}
}
if s.monsterHuntSlots != nil {
count := s.monsterHuntSlots.PresetSlotCount()
if count > math.MaxInt32 {
+62 -1
View File
@@ -4,6 +4,7 @@
package battle
import (
"bytes"
"errors"
"fmt"
"log/slog"
@@ -33,6 +34,30 @@ type Service struct {
commitHealth func(map[uint64]uint64) error
hunting HuntingRuntime
monsterHunt MonsterHuntRuntime
eventBattles []EventBattleRuntime
}
// EventBattleRuntime owns event stage eligibility, costs and settlement while
// the normal battle service transports the client's turn simulation.
type EventBattleRuntime interface {
HandlesBattle(mode uint64) bool
EnterBattle(request []byte, receipt string) ([]byte, error)
CompleteBattle(request []byte, receipt string) ([]byte, error)
}
func (s *Service) AttachEventBattle(runtime EventBattleRuntime) {
if runtime != nil {
s.eventBattles = append(s.eventBattles, runtime)
}
}
func (s *Service) eventBattle(mode uint64) EventBattleRuntime {
for _, runtime := range s.eventBattles {
if runtime.HandlesBattle(mode) {
return runtime
}
}
return nil
}
type MonsterHuntRuntime interface {
@@ -74,6 +99,9 @@ type battleState struct {
phaseStarted bool
phaseSeq uint64
phaseReply []byte
endSeq uint64
endRequest []byte
endReply []byte
}
// BeginSession discards an unfinished battle when LoginUser creates a new
@@ -216,6 +244,14 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
}
monster, _, _ := wire.Varint(request, 3)
var huntResponse []byte
eventRuntime := s.eventBattle(mode)
if eventRuntime != nil {
seq, _, _ := wire.Varint(request, 1)
huntResponse, err = eventRuntime.EnterBattle(request, fmt.Sprintf("%s:%d", s.activeSession, seq))
if err != nil {
return 0, nil, true, fmt.Errorf("battle: enter event: %w", err)
}
}
if isMonsterHunt(mode) {
if s.monsterHunt == nil {
return 0, nil, true, errors.New("battle: monster hunt runtime unavailable")
@@ -256,7 +292,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
}
}
var phases []gamedata.BattlePhase
if !isMonsterHunt(mode) && (s.loadPhases != nil || (s.gameDataRoot != "" && packID > 0 && monster != 0)) {
if !isMonsterHunt(mode) && eventRuntime == nil && (s.loadPhases != nil || (s.gameDataRoot != "" && packID > 0 && monster != 0)) {
loader := s.loadPhases
if loader == nil {
loader = gamedata.BattleDeckPhases
@@ -368,6 +404,13 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
response = wire.AppendVarint(response, 3, seed)
return 14, response, true, nil
case "/BattleEnd":
endSeq, _, _ := wire.Varint(request, 1)
if state.endSeq == endSeq && state.endRequest != nil {
if !bytes.Equal(state.endRequest, request) {
return 0, nil, true, errors.New("battle: changed settlement retry")
}
return 15, append([]byte(nil), state.endReply...), true, nil
}
if !state.entered {
return 0, nil, true, errors.New("battle: end before enter")
}
@@ -379,6 +422,16 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
return 0, nil, true, errors.New("battle: victory before final phase start")
}
response := wire.AppendVarint(nil, 1, result)
if eventRuntime := s.eventBattle(state.mode); eventRuntime != nil {
extra, err := eventRuntime.CompleteBattle(request, state.enterReceipt)
if err != nil {
return 0, nil, true, fmt.Errorf("battle: settle event: %w", err)
}
response = append(response, extra...)
state.rememberEnd(request, response)
state.entered, state.deck, state.pack, state.initialBlue = false, 0, 0, nil
return 15, response, true, nil
}
if isMonsterHunt(state.mode) {
// Monster Hunt owns its remaining HP, progression and daily/season
// rewards. Field HP and ordinary pack rewards must not settle here.
@@ -387,6 +440,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
return 0, nil, true, fmt.Errorf("battle: settle monster hunt: %w", err)
}
response = append(response, extra...)
state.rememberEnd(request, response)
state.entered, state.deck, state.pack, state.initialBlue = false, 0, 0, nil
return 15, response, true, nil
}
@@ -497,6 +551,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
}
response = wire.AppendBytes(response, field, nil)
}
state.rememberEnd(request, response)
state.entered, state.deck, state.pack, state.initialBlue = false, 0, 0, nil
return 15, response, true, nil
case "/BattleExit":
@@ -506,6 +561,12 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
panic("unreachable")
}
func (state *battleState) rememberEnd(request, response []byte) {
state.endSeq, _, _ = wire.Varint(request, 1)
state.endRequest = append([]byte(nil), request...)
state.endReply = append([]byte(nil), response...)
}
func (s *Service) stateLocked() *battleState {
if s.states == nil {
s.states = make(map[string]*battleState)
+72
View File
@@ -0,0 +1,72 @@
package battle
import (
"bytes"
"fmt"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
type fakeEventBattle struct {
enter, end string
rejected bool
}
func (*fakeEventBattle) HandlesBattle(mode uint64) bool { return mode == 17 }
func (e *fakeEventBattle) EnterBattle(_ []byte, receipt string) ([]byte, error) {
e.enter = receipt
if e.rejected {
return nil, fmt.Errorf("stage locked")
}
return wire.AppendVarint(nil, 1, 7), nil
}
func (e *fakeEventBattle) CompleteBattle(_ []byte, receipt string) ([]byte, error) {
e.end = receipt
return wire.AppendBytes(nil, 16, wire.AppendVarint(nil, 3, 77)), nil
}
func TestEventBattleOwnsSettlementAndAvoidsWorldDeck(t *testing.T) {
s := NewService("unused", "unused", nil, func() (int, error) { return 21, nil })
s.BeginSession("event-session")
e := &fakeEventBattle{}
s.AttachEventBattle(e)
s.loadPhases = func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) {
t.Fatal("event read ordinary world deck")
return nil, nil
}
s.AttachCommittedHealth(func(map[uint64]uint64) error { t.Fatal("event altered world health"); return nil })
enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 3, 1), 4, 73), 5, 17)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleStart", wire.AppendVarint(request(2), 2, 73)); err != nil {
t.Fatal(err)
}
code, out, _, err := s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 1))
if err != nil || code != 15 {
t.Fatalf("settlement: %d %v", code, err)
}
if e.enter != "event-session:1" || e.end != e.enter {
t.Fatalf("receipt %q/%q", e.enter, e.end)
}
row, _, _ := wire.Bytes(out, 16)
if !bytes.Equal(row, wire.AppendVarint(nil, 3, 77)) {
t.Fatal("event progress lost")
}
if s.Active() {
t.Fatal("event battle remained active")
}
_, retry, _, err := s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 1))
if err != nil || !bytes.Equal(retry, out) {
t.Fatal("settlement retry lost reply")
}
if _, _, _, err = s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 2)); err == nil {
t.Fatal("changed settlement retry accepted")
}
e.rejected = true
if _, _, _, err = s.Handle("/BattleEnter", enter); err == nil {
t.Fatal("locked stage entered")
}
}
+4 -2
View File
@@ -1,6 +1,7 @@
package battle
import (
"bytes"
"errors"
"testing"
@@ -61,7 +62,8 @@ func TestHuntingBattleUsesLockedEncounterAndSessionReceipt(t *testing.T) {
if bundle, found, _ := wire.Bytes(response, 5); !found || len(bundle) == 0 {
t.Fatal("missing hunting reward")
}
if _, _, _, err := s.Handle("/BattleEnd", end); err == nil {
t.Fatal("completed battle settled twice")
settlements := h.settlements
if _, retry, _, err := s.Handle("/BattleEnd", end); err != nil || !bytes.Equal(retry, response) || h.settlements != settlements {
t.Fatal("completed battle retry settled twice or lost response")
}
}
+194
View File
@@ -0,0 +1,194 @@
package calendar
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"fmt"
"unicode/utf8"
)
const MaxFileSize = 8 * 1024 * 1024
const maxRows = 100000
const maxString = 64 * 1024
const headerSize = 46
var magic = []byte{'B', 'D', '2', 'S', 'C', 'H', 0, 0}
type encoder struct {
data []byte
err error
rows uint64
}
func (e *encoder) u64(v uint64) {
if e.room(8) {
e.data = binary.LittleEndian.AppendUint64(e.data, v)
}
}
func (e *encoder) u32(v uint32) {
if e.room(4) {
e.data = binary.LittleEndian.AppendUint32(e.data, v)
}
}
func (e *encoder) b(v bool) {
if !e.room(1) {
return
}
if v {
e.data = append(e.data, 1)
} else {
e.data = append(e.data, 0)
}
}
func (e *encoder) str(v string) {
if e.err != nil {
return
}
if len(v) > maxString || !utf8.ValidString(v) {
e.err = fmt.Errorf("calendar: invalid/oversized UTF8 string")
return
}
if !e.room(4 + len(v)) {
return
}
e.u32(uint32(len(v)))
e.data = append(e.data, v...)
}
func (e *encoder) count(n int) {
if e.err != nil {
return
}
e.rows += uint64(n)
if n > maxRows || e.rows > maxRows {
e.err = fmt.Errorf("calendar: row limit exceeded")
return
}
e.u32(uint32(n))
}
func (e *encoder) room(n int) bool {
if e.err != nil {
return false
}
if n > MaxFileSize-headerSize-len(e.data) {
e.err = fmt.Errorf("calendar: file size limit exceeded")
return false
}
return true
}
type decoder struct {
data []byte
pos int
err error
rows uint64
}
func (d *decoder) take(n int) []byte {
if d.err != nil {
return nil
}
if n < 0 || n > len(d.data)-d.pos {
d.err = fmt.Errorf("calendar: truncated payload")
return nil
}
v := d.data[d.pos : d.pos+n]
d.pos += n
return v
}
func (d *decoder) u64() uint64 {
v := d.take(8)
if len(v) != 8 {
return 0
}
return binary.LittleEndian.Uint64(v)
}
func (d *decoder) u32() uint32 {
v := d.take(4)
if len(v) != 4 {
return 0
}
return binary.LittleEndian.Uint32(v)
}
func (d *decoder) b() bool {
v := d.take(1)
if len(v) != 1 {
return false
}
if v[0] > 1 {
d.err = fmt.Errorf("calendar: invalid boolean/presence")
}
return v[0] == 1
}
func (d *decoder) str() string {
n := d.u32()
if n > maxString {
d.err = fmt.Errorf("calendar: string limit exceeded")
return ""
}
v := d.take(int(n))
if !utf8.Valid(v) {
d.err = fmt.Errorf("calendar: invalid UTF8")
}
return string(v)
}
func (d *decoder) count() int {
n := d.u32()
d.rows += uint64(n)
if n > maxRows || d.rows > maxRows || uint64(n) > uint64(len(d.data)-d.pos) {
d.err = fmt.Errorf("calendar: row count limit/truncation")
return 0
}
return int(n)
}
// MarshalBinary emits format 1's fixed-order record payload and checksum.
func MarshalBinary(m Manifest) ([]byte, error) {
if m.SchemaVersion != 1 {
return nil, fmt.Errorf("calendar: unsupported schema")
}
e := &encoder{}
e.manifest(m)
if e.err != nil {
return nil, e.err
}
if len(e.data) > MaxFileSize-headerSize {
return nil, fmt.Errorf("calendar: file size limit exceeded")
}
out := append([]byte(nil), magic...)
out = binary.LittleEndian.AppendUint16(out, 1)
out = binary.LittleEndian.AppendUint32(out, uint32(len(e.data)))
sum := sha256.Sum256(e.data)
out = append(out, sum[:]...)
return append(out, e.data...), nil
}
// UnmarshalBinary rejects damaged, unsupported, oversized and trailing data.
func UnmarshalBinary(raw []byte) (Manifest, error) {
var m Manifest
if len(raw) < headerSize || len(raw) > MaxFileSize {
return m, fmt.Errorf("calendar: invalid file size")
}
if !bytes.Equal(raw[:8], magic) || binary.LittleEndian.Uint16(raw[8:10]) != 1 {
return m, fmt.Errorf("calendar: unsupported magic/format")
}
n := binary.LittleEndian.Uint32(raw[10:14])
if uint64(n) != uint64(len(raw)-headerSize) {
return m, fmt.Errorf("calendar: payload length mismatch")
}
payload := raw[headerSize:]
sum := sha256.Sum256(payload)
if !bytes.Equal(raw[14:46], sum[:]) {
return m, fmt.Errorf("calendar: checksum mismatch")
}
d := &decoder{data: payload}
m = d.manifest()
if d.err != nil {
return Manifest{}, d.err
}
if d.pos != len(payload) {
return Manifest{}, fmt.Errorf("calendar: trailing payload")
}
m.SchemaVersion = 1
return m, nil
}
+115
View File
@@ -0,0 +1,115 @@
package calendar
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestInstalledBinaryFilesReencodeExactly(t *testing.T) {
paths, e := filepath.Glob("../../../../schedules/*.bd2schedule")
if e != nil || len(paths) != 6 {
t.Fatalf("installed files=%d err=%v", len(paths), e)
}
for _, path := range paths {
raw, e := os.ReadFile(path)
if e != nil {
t.Fatal(e)
}
m, e := UnmarshalBinary(raw)
if e != nil {
t.Fatal(e)
}
actual, e := MarshalBinary(m)
if e != nil {
t.Fatal(e)
}
if !bytes.Equal(raw, actual) {
t.Fatalf("canonical bytes changed %s", path)
}
}
}
func TestBinaryRejectsInvalidPresence(t *testing.T) {
raw, e := MarshalBinary(baseManifest())
if e != nil {
t.Fatal(e)
}
d := &decoder{data: raw[headerSize:]}
d.str()
d.str()
d.str()
d.count()
d.count()
d.count()
raw[headerSize+d.pos] = 2
sum := sha256.Sum256(raw[headerSize:])
copy(raw[14:46], sum[:])
if _, e = UnmarshalBinary(raw); e == nil {
t.Fatal("invalid optional presence accepted despite valid checksum")
}
}
func TestBinaryRoundTripAllRecords(t *testing.T) {
m := baseManifest()
m.Events = []Event{{UID: 5, Type: 11, ID: 7, Start: "2026-01-01T00:00:00Z", End: "2026-02-01T00:00:00Z"}}
m.Gacha = []Gacha{{GroupID: 2, FreeCountBonus: true}}
m.StepUps = []Gacha{{GroupID: 3}}
m.Regular = &Regular{Contents: []Content{{ID: 4, Current: Season{ID: 8, Error: true}, Next: Season{ID: 9, Return: true}}}}
m.MonsterHunt = &MonsterHunt{Seasons: []Hunt{{HuntID: 6, CostumeBanIDs: []uint64{5}, BurstBanIDs: []uint64{7}, IndependentFlag: true}}, History: []HuntHistory{{Season: 5, HuntID: 6, Hidden: true}}}
m.CashProducts = []CashProduct{{GroupID: 1, ProductID: 2, EventIndex: 3}}
m.EventHubs = []EventHub{{HubID: 4, Settings: []HubSetting{{Slot: 5, EventUIDs: []uint64{6, 7}}}}}
m.MiniGameHubs = []MiniGameHub{{Slot: 8, EventUID: 5}}
raw, e := MarshalBinary(m)
if e != nil {
t.Fatal(e)
}
actual, e := UnmarshalBinary(raw)
if e != nil {
t.Fatal(e)
}
if !reflect.DeepEqual(actual, m) {
t.Fatalf("roundtrip changed records: %+v", actual)
}
}
func TestBinaryRejectsCorruptionLimitsAndTrailing(t *testing.T) {
raw, e := MarshalBinary(baseManifest())
if e != nil {
t.Fatal(e)
}
for n := 0; n < len(raw); n++ {
if _, e = UnmarshalBinary(raw[:n]); e == nil {
t.Fatalf("accepted truncation %d", n)
}
}
for _, offset := range []int{0, 8, 10, 14, 46} {
bad := append([]byte(nil), raw...)
bad[offset] ^= 0xff
if _, e = UnmarshalBinary(bad); e == nil {
t.Fatalf("accepted corruption %d", offset)
}
}
if _, e = UnmarshalBinary(append(raw, 0)); e == nil {
t.Fatal("accepted trailing data")
}
m := baseManifest()
m.Revision = string(make([]byte, maxString+1))
if _, e = MarshalBinary(m); e == nil {
t.Fatal("oversized string accepted")
}
m = baseManifest()
m.Events = make([]Event, maxRows+1)
if _, e = MarshalBinary(m); e == nil {
t.Fatal("oversized count accepted")
}
bad := append([]byte(nil), raw...)
binary.LittleEndian.PutUint32(bad[46:50], ^uint32(0))
sum := sha256.Sum256(bad[46:])
copy(bad[14:46], sum[:])
if _, e = UnmarshalBinary(bad); e == nil {
t.Fatal("overflow string length accepted")
}
}
+130
View File
@@ -0,0 +1,130 @@
package calendar
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"fmt"
)
// ValidateDesign checks playable identities against their domain's installed
// GameData. Announcement-only types 3/6/14/15/16/18 have no local gameplay
// design resolver and receive only the manifest's protocol/format validation.
func (s *Set) ValidateDesign(root, version string) error {
cash, e := gamedata.LoadCashCatalog(root, version)
if e != nil {
return e
}
keys := map[gamedata.CashProductKey]bool{}
for _, v := range cash.Products {
keys[v.Key] = true
}
for _, v := range s.CashProducts {
if !keys[gamedata.CashProductKey{GroupID: v.GroupID, ProductID: v.ProductID, SaleGroup: v.SaleGroup}] {
return fmt.Errorf("calendar: cash product %d/%d/%d missing GameData", v.GroupID, v.ProductID, v.SaleGroup)
}
}
if s.MonsterHunt != nil {
ids, e := gamedata.LoadMonsterHuntIDs(root, version)
if e != nil {
return e
}
for _, v := range s.MonsterHunt.Seasons {
if !ids[v.HuntID] {
return fmt.Errorf("calendar: active/playable hunt %d missing GameData", v.HuntID)
}
}
}
play, e := gamedata.LoadEventPlayCatalog(root, version)
if e != nil {
return e
}
tasks, e := gamedata.LoadEventTasksDesign(root, version)
if e != nil {
return e
}
exchange, e := gamedata.LoadEventExchangeCatalog(root, version)
if e != nil {
return e
}
actions, e := gamedata.LoadEventActionsDesign(root, version)
if e != nil {
return e
}
packs, e := gamedata.LoadCalendarPackIDs(root, version)
if e != nil {
return e
}
for _, h := range s.EventHubs {
if _, e = play.Row("PackEventHubTable", 14, h.HubID); e != nil {
return fmt.Errorf("calendar: hub %d: %w", h.HubID, e)
}
}
uid := map[uint64]events.Schedule{}
for _, v := range s.Events {
if v.UID != 0 {
uid[v.UID] = v
}
}
for _, h := range s.MiniGameHubs {
v, ok := uid[h.EventUID]
if !ok || v.Type != 11 {
return fmt.Errorf("calendar: mini game slot %d references non-minigame event %d", h.Slot, h.EventUID)
}
if _, e := play.Row("PackEventMiniGameTable", 8, v.ID); e != nil {
return e
}
}
for _, v := range s.Events {
valid := true
var err error
switch v.Type {
case 0:
_, valid = tasks.Attendance[v.ID]
case 1:
valid = false
for k := range tasks.LimitRewards {
if k[0] == v.ID {
valid = true
break
}
}
case 4:
_, valid = tasks.MissionGroups[v.ID]
case 5:
_, valid = tasks.Passes[v.ID]
case 7:
_, valid = exchange.Groups[v.ID]
case 8:
valid = packs[v.ID]
case 9:
_, err = play.Row("PackEventBattleGroupTable", 3, v.ID)
case 10:
_, err = play.Row("PackEventStoryGroupTable", 1, v.ID)
case 11:
_, err = play.Row("PackEventMiniGameTable", 8, v.ID)
case 12, 13, 17, 19:
_, err = gamedata.LoadEventGame(root, version, v.Type, v.ID)
case 20:
_, valid = actions.Row("TacticsBingoGroupTable", 3, v.ID)
case 21:
_, valid = actions.Row("FieldSpawnEventTable", 5, v.ID)
case 22:
_, valid = actions.Row("FireworksTable", 4, v.ID)
case 23:
_, valid = actions.Row("VotingEventTable", 6, v.ID)
case 24:
_, valid = actions.Row("FriendshipSpecialEpisodeTable", 5, v.ID)
case 25:
id := v.SubID
if id == 0 {
id = v.ID
}
_, valid = actions.Row("CafeteriaEventTable", 5, id)
}
if !valid || err != nil {
return fmt.Errorf("calendar: event uid %d type %d id %d missing GameData: %v", v.UID, v.Type, v.ID, err)
}
}
return nil
}
+379
View File
@@ -0,0 +1,379 @@
package calendar
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gacha"
"bd2server/internal/server/schedule"
"fmt"
"math"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
func timestamp(raw string) (uint64, error) {
t, e := time.Parse(time.RFC3339Nano, raw)
if e != nil {
return 0, fmt.Errorf("invalid RFC3339 timestamp %q", raw)
}
ms := t.UnixMilli()
if ms <= 0 || t.Nanosecond()%1000000 != 0 {
return 0, fmt.Errorf("timestamp must be positive with millisecond precision: %q", raw)
}
return uint64(ms), nil
}
func window(start, end string, equal bool) (uint64, uint64, error) {
a, e := timestamp(start)
if e != nil {
return 0, 0, e
}
b, e := timestamp(end)
if e != nil {
return 0, 0, e
}
if b < a || (!equal && b == a) {
return 0, 0, fmt.Errorf("reversed/empty calendar window")
}
return a, b, nil
}
func int32s(values ...uint64) error {
for _, v := range values {
if v > math.MaxInt32 {
return fmt.Errorf("identity/count %d exceeds protocol int32", v)
}
}
return nil
}
func convertSeason(s Season) (schedule.Season, error) {
a, b, e := window(s.Start, s.End, true)
if e != nil {
return schedule.Season{}, e
}
if s.ID == 0 {
return schedule.Season{}, fmt.Errorf("missing season identity")
}
if e = int32s(s.ID, s.RankRewardGroupID); e != nil {
return schedule.Season{}, e
}
return schedule.Season{ID: s.ID, StartMilliseconds: a, EndMilliseconds: b, RankRewardGroupID: s.RankRewardGroupID, Error: s.Error, Return: s.Return}, nil
}
// LoadDirectory validates the whole directory before returning any calendar.
// Every calendar file must use the .bd2schedule suffix.
// Directories and symlinks are
// rejected so an accidentally omitted calendar never produces a partial set.
func LoadDirectory(dir, gameVersion, gameDataVersion string) (*Set, error) {
entries, e := os.ReadDir(dir)
if e != nil {
return nil, fmt.Errorf("calendar: read directory: %w", e)
}
set := &Set{GachaSeed: &gacha.ScheduleSeed{ClientVersion: gameVersion}}
seen := map[string]bool{}
claim := func(key string) error {
if seen[key] {
return fmt.Errorf("duplicate calendar identity %s", key)
}
seen[key] = true
return nil
}
files := 0
for _, entry := range entries {
if !entry.Type().IsRegular() || !strings.HasSuffix(entry.Name(), ".bd2schedule") {
return nil, fmt.Errorf("calendar: unsupported entry %s", entry.Name())
}
info, e := entry.Info()
if e != nil {
return nil, e
}
if info.Size() > MaxFileSize {
return nil, fmt.Errorf("calendar: %s: file size limit exceeded", entry.Name())
}
raw, e := os.ReadFile(filepath.Join(dir, entry.Name()))
if e != nil {
return nil, e
}
m, e := UnmarshalBinary(raw)
if e != nil {
return nil, fmt.Errorf("calendar: %s: %w", entry.Name(), e)
}
if m.SchemaVersion != 1 || strings.TrimSpace(m.Revision) == "" || m.GameVersion != gameVersion || m.GameDataVersion != gameDataVersion || gameVersion == "" || gameDataVersion == "" {
return nil, fmt.Errorf("calendar: %s: incompatible schema/version or missing revision", entry.Name())
}
if e = merge(set, m, claim); e != nil {
return nil, fmt.Errorf("calendar: %s: %w", entry.Name(), e)
}
set.Revisions = append(set.Revisions, m.Revision)
files++
}
if files == 0 {
return nil, fmt.Errorf("calendar: empty directory")
}
sort.Slice(set.EventHubs, func(i, j int) bool { return set.EventHubs[i].UID < set.EventHubs[j].UID })
sort.Slice(set.MiniGameHubs, func(i, j int) bool { return set.MiniGameHubs[i].Slot < set.MiniGameHubs[j].Slot })
sort.Slice(set.Events, func(i, j int) bool {
a, b := set.Events[i], set.Events[j]
if a.UID != b.UID {
return a.UID < b.UID
}
if a.Type != b.Type {
return a.Type < b.Type
}
if a.ID != b.ID {
return a.ID < b.ID
}
return a.SubID < b.SubID
})
sortWindows := func(w []gacha.ScheduleWindow) {
sort.Slice(w, func(i, j int) bool {
if w[i].GroupID != w[j].GroupID {
return w[i].GroupID < w[j].GroupID
}
return w[i].StartTime < w[j].StartTime
})
}
sortWindows(set.GachaSeed.Schedules)
sortWindows(set.GachaSeed.StepUps)
if e = set.GachaSeed.Validate(gameVersion); e != nil {
return nil, e
}
if set.RegularService != nil {
sort.Slice(set.RegularService.Contents, func(i, j int) bool { return set.RegularService.Contents[i].ID < set.RegularService.Contents[j].ID })
sort.Slice(set.RegularService.Regular, func(i, j int) bool {
return set.RegularService.Regular[i].ContentID < set.RegularService.Regular[j].ContentID
})
if e = set.RegularService.Validate(); e != nil {
return nil, e
}
}
sort.Slice(set.CashProducts, func(i, j int) bool {
a, b := set.CashProducts[i], set.CashProducts[j]
if a.GroupID != b.GroupID {
return a.GroupID < b.GroupID
}
if a.ProductID != b.ProductID {
return a.ProductID < b.ProductID
}
return a.SaleGroup < b.SaleGroup
})
if set.MonsterHunt != nil {
sort.Slice(set.MonsterHunt.Seasons, func(i, j int) bool {
return set.MonsterHunt.Seasons[i].Season.ID < set.MonsterHunt.Seasons[j].Season.ID
})
sort.Slice(set.MonsterHunt.History, func(i, j int) bool { return set.MonsterHunt.History[i].Season < set.MonsterHunt.History[j].Season })
}
return set, nil
}
func merge(s *Set, m Manifest, claim func(string) error) error {
for _, v := range m.EventHubs {
if e := claim(fmt.Sprintf("hub:%d", v.UID)); e != nil {
return e
}
if v.HubID == 0 {
return fmt.Errorf("missing hub identity")
}
if e := int32s(v.UID, v.HubID); e != nil {
return e
}
a, b, e := window(v.Start, v.End, false)
if e != nil {
return e
}
play, e := timestamp(v.PlayEnd)
if e != nil {
return e
}
if play < a || play > b {
return fmt.Errorf("invalid hub play end")
}
slots := map[uint64]bool{}
for _, setting := range v.Settings {
if slots[setting.Slot] {
return fmt.Errorf("duplicate hub slot")
}
slots[setting.Slot] = true
if e := int32s(setting.Slot, setting.ProgressType); e != nil {
return e
}
if e := int32s(setting.EventUIDs...); e != nil {
return e
}
ids := map[uint64]bool{}
for _, uid := range setting.EventUIDs {
if ids[uid] {
return fmt.Errorf("duplicate hub event uid")
}
ids[uid] = true
}
}
s.EventHubs = append(s.EventHubs, v)
}
for _, v := range m.MiniGameHubs {
if e := claim(fmt.Sprintf("mini_game:%d", v.Slot)); e != nil {
return e
}
if e := int32s(v.Slot, v.EventUID, v.ProgressType); e != nil {
return e
}
s.MiniGameHubs = append(s.MiniGameHubs, v)
}
for _, v := range m.Events {
if e := claim(eventIdentity(v)); e != nil {
return e
}
if e := int32s(v.Type, v.ID, v.SubID); e != nil {
return e
}
a, b, e := eventWindow(v.Start, v.End)
if e != nil {
return e
}
s.Events = append(s.Events, events.Schedule{UID: v.UID, Type: v.Type, ID: v.ID, SubID: v.SubID, Start: a, End: b})
}
r := events.NewRegistry()
if e := r.Replace(s.Events); e != nil {
return e
}
for n, rows := range [][]Gacha{m.Gacha, m.StepUps} {
for _, v := range rows {
if e := int32s(v.GroupID); e != nil {
return e
}
a, b, e := window(v.Start, v.End, false)
if e != nil {
return e
}
w := gacha.ScheduleWindow{GroupID: v.GroupID, StartTime: a, EndTime: b, FreeCountBonus: v.FreeCountBonus, CashCountBonus: v.CashCountBonus}
if n == 0 {
s.GachaSeed.Schedules = append(s.GachaSeed.Schedules, w)
} else {
s.GachaSeed.StepUps = append(s.GachaSeed.StepUps, w)
}
}
}
if m.Regular != nil {
if e := claim("regular"); e != nil {
return e
}
r := m.Regular
s.RegularService = &schedule.Service{Version: m.GameVersion, CalculateMilliseconds: r.CalculateMilliseconds, Regular: r.Regular}
for _, v := range r.Contents {
a, e := convertSeason(v.Current)
if e != nil {
return e
}
b, e := convertSeason(v.Next)
if e != nil {
return e
}
s.RegularService.Contents = append(s.RegularService.Contents, schedule.Content{ID: v.ID, Current: a, Next: b})
}
}
if m.MonsterHunt != nil {
if e := claim("monster_hunt"); e != nil {
return e
}
h := m.MonsterHunt
if e := int32s(h.StartRegularSeason); e != nil {
return e
}
for _, v := range h.Seasons {
if e := claim(fmt.Sprintf("hunt:%d", v.Season.ID)); e != nil {
return e
}
if v.HuntID == 0 {
return fmt.Errorf("missing hunt identity")
}
if e := int32s(v.HuntID, v.InfoOpenDay, v.RankRewardGroupID); e != nil {
return e
}
if _, e := convertSeason(v.Season); e != nil {
return e
}
end, e := timestamp(v.CalculateEndAt)
if e != nil {
return e
}
seasonEnd, _ := timestamp(v.Season.End)
if end < seasonEnd {
return fmt.Errorf("hunt calculation ends before season")
}
for _, ids := range [][]uint64{v.CostumeBanIDs, v.BurstBanIDs} {
if e := int32s(ids...); e != nil {
return e
}
seen := map[uint64]bool{}
for _, id := range ids {
if id == 0 || seen[id] {
return fmt.Errorf("invalid hunt ban identity")
}
seen[id] = true
}
}
}
for _, v := range h.History {
if e := claim(fmt.Sprintf("hunt_history:%d", v.Season)); e != nil {
return e
}
if v.Season == 0 || v.HuntID == 0 {
return fmt.Errorf("missing history identity")
}
if e := int32s(v.Season, v.HuntID); e != nil {
return e
}
}
s.MonsterHunt = h
}
for _, v := range m.CashProducts {
if e := claim(fmt.Sprintf("cash:%d:%d:%d", v.GroupID, v.ProductID, v.SaleGroup)); e != nil {
return e
}
if v.GroupID == 0 || v.ProductID == 0 || v.EventIndex > math.MaxInt64 {
return fmt.Errorf("invalid cash identity")
}
if e := int32s(v.GroupID, v.ProductID, v.SaleGroup, v.EndDelayMinutes); e != nil {
return e
}
var a, b uint64
var e error
if v.Start != "" {
a, e = timestamp(v.Start)
if e != nil {
return e
}
}
if v.End != "" {
b, e = timestamp(v.End)
if e != nil {
return e
}
if b <= a {
return fmt.Errorf("invalid cash window")
}
}
s.CashProducts = append(s.CashProducts, v)
}
return nil
}
func eventIdentity(v Event) string {
if v.UID != 0 {
return fmt.Sprintf("event:%d", v.UID)
}
return fmt.Sprintf("event:0:%d:%d:%d", v.Type, v.ID, v.SubID)
}
func eventWindow(start, end string) (int64, int64, error) {
a, e := time.Parse(time.RFC3339Nano, start)
if e != nil {
return 0, 0, e
}
b, e := time.Parse(time.RFC3339Nano, end)
if e != nil {
return 0, 0, e
}
if a.Nanosecond()%1000000 != 0 || b.Nanosecond()%1000000 != 0 || b.UnixMilli() <= a.UnixMilli() {
return 0, 0, fmt.Errorf("invalid event window")
}
return a.UnixMilli(), b.UnixMilli(), nil
}
+122
View File
@@ -0,0 +1,122 @@
package calendar
import (
"bd2server/internal/server/readonly"
"bd2server/internal/server/wire"
"os"
"path/filepath"
"testing"
)
func writeManifest(t *testing.T, dir, name string, m Manifest) {
t.Helper()
raw, e := MarshalBinary(m)
if e != nil {
t.Fatal(e)
}
if e = os.WriteFile(filepath.Join(dir, name), raw, 0600); e != nil {
t.Fatal(e)
}
}
func baseManifest() Manifest {
return Manifest{SchemaVersion: 1, Revision: "2026-10-05", GameVersion: "2.35.10", GameDataVersion: "2.35.10"}
}
func TestInstalledCalendarsContainAllRuntimeDomains(t *testing.T) {
set, e := LoadDirectory("../../../../schedules", "2.35.10", "20260923193640")
if e != nil {
t.Fatal(e)
}
if len(set.Events) != 54 || len(set.GachaSeed.Schedules) != 11 || len(set.GachaSeed.StepUps) != 2 || set.RegularService == nil || len(set.RegularService.Contents) != 9 || set.MonsterHunt == nil || len(set.MonsterHunt.History) != 78 || len(set.CashProducts) != 37 || len(set.EventHubs) != 2 || len(set.MiniGameHubs) != 6 {
t.Fatalf("installed calendar omitted a domain: events=%d gacha=%d steps=%d cash=%d hubs=%d mini=%d", len(set.Events), len(set.GachaSeed.Schedules), len(set.GachaSeed.StepUps), len(set.CashProducts), len(set.EventHubs), len(set.MiniGameHubs))
}
}
func TestMultiFileCalendarAtomicDeterministicAndIndependentRevision(t *testing.T) {
dir := t.TempDir()
a := baseManifest()
a.Revision = "a"
a.Gacha = []Gacha{{GroupID: 9, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}}
writeManifest(t, dir, "z.bd2schedule", a)
b := baseManifest()
b.Revision = "b"
b.Gacha = []Gacha{{GroupID: 1, Start: "2027-01-01T09:00:00+09:00", End: "2027-01-02T09:00:00+09:00"}, {GroupID: 9, Start: "2026-10-02T00:00:00Z", End: "2026-10-03T00:00:00Z"}}
writeManifest(t, dir, "a.bd2schedule", b)
set, e := LoadDirectory(dir, "2.35.10", "2.35.10")
if e != nil {
t.Fatal(e)
}
if len(set.GachaSeed.Schedules) != 3 || set.GachaSeed.Schedules[0].GroupID != 1 || set.Revisions[0] != "b" {
t.Fatalf("unexpected merged set %+v", set)
}
b.Revision = "changed without game update"
b.Gacha[0].End = "2027-01-03T09:00:00+09:00"
writeManifest(t, dir, "a.bd2schedule", b)
changed, e := LoadDirectory(dir, "2.35.10", "2.35.10")
if e != nil || changed.GachaSeed.Schedules[0].EndTime == set.GachaSeed.Schedules[0].EndTime {
t.Fatalf("calendar update ignored: %v", e)
}
if e = os.WriteFile(filepath.Join(dir, "broken.bd2schedule"), []byte("broken binary"), 0600); e != nil {
t.Fatal(e)
}
if partial, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil || partial != nil {
t.Fatal("bad file returned partial calendar")
}
}
func TestStrictCalendarRejectsConflictsAndInvalidData(t *testing.T) {
for _, mutate := range []func(*Manifest){func(m *Manifest) { m.GameDataVersion = "wrong" }, func(m *Manifest) { m.Gacha[0].Start = "2026-10-01" }, func(m *Manifest) { m.Gacha[0].End = m.Gacha[0].Start }, func(m *Manifest) { m.Gacha = append(m.Gacha, m.Gacha[0]) }, func(m *Manifest) { m.Gacha[0].GroupID = 1 << 32 }} {
dir := t.TempDir()
m := baseManifest()
m.Gacha = []Gacha{{GroupID: 1, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}}
mutate(&m)
writeManifest(t, dir, "one.bd2schedule", m)
if _, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil {
t.Fatal("invalid calendar accepted")
}
}
dir := t.TempDir()
m := baseManifest()
m.Events = []Event{{UID: 77, Type: 12, ID: 3, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}}
writeManifest(t, dir, "a.bd2schedule", m)
writeManifest(t, dir, "b.bd2schedule", m)
if _, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil {
t.Fatal("duplicate UID across files accepted")
}
}
func TestReadonlyCalendarReplacementPreservesStaticResponses(t *testing.T) {
set := &Set{CashProducts: []CashProduct{{GroupID: 11, ProductID: 22, EventIndex: 99}}, MonsterHunt: &MonsterHunt{StartRegularSeason: 5, History: []HuntHistory{{Season: 78, HuntID: 73, Hidden: true}}}}
old := &readonly.Seed{Version: "2.35.10", Responses: map[string]readonly.Response{"/Static": {PacketCode: 17, Fields: []readonly.Field{scalar(1, 10)}}, "/CashShopInfo": {PacketCode: 60, Fields: []readonly.Field{scalar(2, 1)}}}}
result, e := set.ApplyReadonly(old)
if e != nil {
t.Fatal(e)
}
if len(old.Responses["/CashShopInfo"].Fields) != 1 || result.Responses["/Static"].PacketCode != 17 {
t.Fatal("source seed changed/static endpoint lost")
}
_, raw, handled, e := result.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 1))
if e != nil || !handled {
t.Fatal(e)
}
p, found, e := wire.Bytes(raw, 1)
if e != nil || !found {
t.Fatal("missing product")
}
id, _, _ := wire.Varint(p, 8)
if id != 99 {
t.Fatalf("event index=%d", id)
}
if _, found, _ = wire.Varint(raw, 2); found {
t.Fatal("stale reset timestamp retained")
}
}
func TestInstalledCalendarRealGameData(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
set, e := LoadDirectory("../../../../schedules", "2.35.10", "20260923193640")
if e != nil {
t.Fatal(e)
}
if e = set.ValidateDesign(root, "20260923193640"); e != nil {
t.Fatal(e)
}
}
+91
View File
@@ -0,0 +1,91 @@
package calendar
import (
"bd2server/internal/server/readonly"
"fmt"
)
func scalar(n int, v uint64) readonly.Field { return readonly.Field{Number: n, Type: 0, Varint: v} }
func message(n int, f []readonly.Field) readonly.Field {
return readonly.Field{Number: n, Type: 2, Fields: f}
}
func flag(n int, b bool) readonly.Field {
if b {
return scalar(n, 1)
}
return scalar(n, 0)
}
func seasonFields(s Season) []readonly.Field {
a, _ := timestamp(s.Start)
b, _ := timestamp(s.End)
return []readonly.Field{scalar(1, s.ID), scalar(2, a), scalar(3, b), flag(4, s.Error), flag(5, s.Return), scalar(6, s.RankRewardGroupID)}
}
// ApplyReadonly returns a separate seed with every calendar endpoint encoded
// entirely from the loaded files. Commerce supplies reset times from its
// current reset rules and clock when serving CashShopInfo.
func (s *Set) ApplyReadonly(seed *readonly.Seed) (*readonly.Seed, error) {
if s == nil || seed == nil {
return nil, fmt.Errorf("calendar: missing calendar/static seed")
}
result := &readonly.Seed{Version: seed.Version, Responses: make(map[string]readonly.Response, len(seed.Responses))}
for path, response := range seed.Responses {
result.Responses[path] = response
}
var hunt []readonly.Field
if h := s.MonsterHunt; h != nil {
for _, v := range h.Seasons {
end, _ := timestamp(v.CalculateEndAt)
f := []readonly.Field{message(1, seasonFields(v.Season)), scalar(2, v.HuntID), scalar(3, v.InfoOpenDay), scalar(4, end), flag(5, v.ErrorFlag), flag(6, v.IndependentFlag), scalar(7, v.RankRewardGroupID)}
for _, id := range v.CostumeBanIDs {
f = append(f, scalar(8, id))
}
for _, id := range v.BurstBanIDs {
f = append(f, scalar(9, id))
}
hunt = append(hunt, message(1, f))
}
hunt = append(hunt, scalar(2, h.StartRegularSeason))
for _, v := range h.History {
hunt = append(hunt, message(3, []readonly.Field{scalar(1, v.Season), scalar(2, v.HuntID), flag(3, v.ErrorFlag), flag(4, v.Hidden)}))
}
}
result.Responses["/MonsterHuntScheduleInfo"] = readonly.Response{PacketCode: 0, Fields: hunt}
var cash []readonly.Field
for _, v := range s.CashProducts {
var a, b uint64
if v.Start != "" {
a, _ = timestamp(v.Start)
}
if v.End != "" {
b, _ = timestamp(v.End)
}
cash = append(cash, message(1, []readonly.Field{scalar(1, v.GroupID), scalar(2, v.ProductID), scalar(3, v.SaleGroup), scalar(4, a), scalar(5, b), scalar(6, v.EndDelayMinutes), scalar(8, v.EventIndex)}))
}
result.Responses["/CashShopInfo"] = readonly.Response{PacketCode: 60, Fields: cash}
var hubs []readonly.Field
for _, v := range s.EventHubs {
a, _ := timestamp(v.Start)
b, _ := timestamp(v.PlayEnd)
c, _ := timestamp(v.End)
f := []readonly.Field{scalar(1, v.UID), scalar(2, v.HubID), scalar(3, a), scalar(4, b), scalar(5, c)}
for _, setting := range v.Settings {
sf := []readonly.Field{scalar(1, setting.Slot), scalar(2, setting.ProgressType)}
for _, uid := range setting.EventUIDs {
sf = append(sf, scalar(3, uid))
}
f = append(f, message(6, sf))
}
hubs = append(hubs, message(1, f))
}
result.Responses["/EventHubInfo"] = readonly.Response{PacketCode: 0, Fields: hubs}
var mini []readonly.Field
for _, v := range s.MiniGameHubs {
mini = append(mini, message(1, []readonly.Field{scalar(1, v.Slot), scalar(2, v.EventUID), scalar(3, v.ProgressType)}))
}
result.Responses["/MiniGameHubInfo"] = readonly.Response{PacketCode: 390, Fields: mini}
if err := result.Validate(); err != nil {
return nil, err
}
return result, nil
}
+312
View File
@@ -0,0 +1,312 @@
package calendar
import "bd2server/internal/server/schedule"
func (e *encoder) manifest(v Manifest) {
e.str(v.Revision)
e.str(v.GameVersion)
e.str(v.GameDataVersion)
e.count(len(v.Events))
for _, item := range v.Events {
e.event(item)
}
e.count(len(v.Gacha))
for _, item := range v.Gacha {
e.gacha(item)
}
e.count(len(v.StepUps))
for _, item := range v.StepUps {
e.gacha(item)
}
e.b(v.Regular != nil)
if v.Regular != nil {
e.regular(*v.Regular)
}
e.b(v.MonsterHunt != nil)
if v.MonsterHunt != nil {
e.monsterhunt(*v.MonsterHunt)
}
e.count(len(v.CashProducts))
for _, item := range v.CashProducts {
e.cashproduct(item)
}
e.count(len(v.EventHubs))
for _, item := range v.EventHubs {
e.eventhub(item)
}
e.count(len(v.MiniGameHubs))
for _, item := range v.MiniGameHubs {
e.minigamehub(item)
}
}
func (d *decoder) manifest() Manifest {
var v Manifest
v.Revision = d.str()
v.GameVersion = d.str()
v.GameDataVersion = d.str()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Events = append(v.Events, d.event())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Gacha = append(v.Gacha, d.gacha())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.StepUps = append(v.StepUps, d.gacha())
}
if d.b() {
item := d.regular()
v.Regular = &item
}
if d.b() {
item := d.monsterhunt()
v.MonsterHunt = &item
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.CashProducts = append(v.CashProducts, d.cashproduct())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.EventHubs = append(v.EventHubs, d.eventhub())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.MiniGameHubs = append(v.MiniGameHubs, d.minigamehub())
}
return v
}
func (e *encoder) event(v Event) {
e.u64(v.UID)
e.u64(v.Type)
e.u64(v.ID)
e.u64(v.SubID)
e.str(v.Start)
e.str(v.End)
}
func (d *decoder) event() Event {
var v Event
v.UID = d.u64()
v.Type = d.u64()
v.ID = d.u64()
v.SubID = d.u64()
v.Start = d.str()
v.End = d.str()
return v
}
func (e *encoder) gacha(v Gacha) {
e.u64(v.GroupID)
e.str(v.Start)
e.str(v.End)
e.b(v.FreeCountBonus)
e.b(v.CashCountBonus)
}
func (d *decoder) gacha() Gacha {
var v Gacha
v.GroupID = d.u64()
v.Start = d.str()
v.End = d.str()
v.FreeCountBonus = d.b()
v.CashCountBonus = d.b()
return v
}
func (e *encoder) season(v Season) {
e.u64(v.ID)
e.str(v.Start)
e.str(v.End)
e.u64(v.RankRewardGroupID)
e.b(v.Error)
e.b(v.Return)
}
func (d *decoder) season() Season {
var v Season
v.ID = d.u64()
v.Start = d.str()
v.End = d.str()
v.RankRewardGroupID = d.u64()
v.Error = d.b()
v.Return = d.b()
return v
}
func (e *encoder) content(v Content) {
e.u64(v.ID)
e.season(v.Current)
e.season(v.Next)
}
func (d *decoder) content() Content {
var v Content
v.ID = d.u64()
v.Current = d.season()
v.Next = d.season()
return v
}
func (e *encoder) regular(v Regular) {
e.u64(v.CalculateMilliseconds)
e.count(len(v.Contents))
for _, item := range v.Contents {
e.content(item)
}
e.count(len(v.Regular))
for _, item := range v.Regular {
e.regularseason(item)
}
}
func (d *decoder) regular() Regular {
var v Regular
v.CalculateMilliseconds = d.u64()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Contents = append(v.Contents, d.content())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Regular = append(v.Regular, d.regularseason())
}
return v
}
func (e *encoder) hunt(v Hunt) {
e.season(v.Season)
e.u64(v.HuntID)
e.u64(v.InfoOpenDay)
e.str(v.CalculateEndAt)
e.b(v.ErrorFlag)
e.b(v.IndependentFlag)
e.u64(v.RankRewardGroupID)
e.count(len(v.CostumeBanIDs))
for _, item := range v.CostumeBanIDs {
e.u64(item)
}
e.count(len(v.BurstBanIDs))
for _, item := range v.BurstBanIDs {
e.u64(item)
}
}
func (d *decoder) hunt() Hunt {
var v Hunt
v.Season = d.season()
v.HuntID = d.u64()
v.InfoOpenDay = d.u64()
v.CalculateEndAt = d.str()
v.ErrorFlag = d.b()
v.IndependentFlag = d.b()
v.RankRewardGroupID = d.u64()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.CostumeBanIDs = append(v.CostumeBanIDs, d.u64())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.BurstBanIDs = append(v.BurstBanIDs, d.u64())
}
return v
}
func (e *encoder) hunthistory(v HuntHistory) {
e.u64(v.Season)
e.u64(v.HuntID)
e.b(v.ErrorFlag)
e.b(v.Hidden)
}
func (d *decoder) hunthistory() HuntHistory {
var v HuntHistory
v.Season = d.u64()
v.HuntID = d.u64()
v.ErrorFlag = d.b()
v.Hidden = d.b()
return v
}
func (e *encoder) monsterhunt(v MonsterHunt) {
e.count(len(v.Seasons))
for _, item := range v.Seasons {
e.hunt(item)
}
e.u64(v.StartRegularSeason)
e.count(len(v.History))
for _, item := range v.History {
e.hunthistory(item)
}
}
func (d *decoder) monsterhunt() MonsterHunt {
var v MonsterHunt
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Seasons = append(v.Seasons, d.hunt())
}
v.StartRegularSeason = d.u64()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.History = append(v.History, d.hunthistory())
}
return v
}
func (e *encoder) cashproduct(v CashProduct) {
e.u64(v.GroupID)
e.u64(v.ProductID)
e.u64(v.SaleGroup)
e.str(v.Start)
e.str(v.End)
e.u64(v.EndDelayMinutes)
e.u64(v.EventIndex)
}
func (d *decoder) cashproduct() CashProduct {
var v CashProduct
v.GroupID = d.u64()
v.ProductID = d.u64()
v.SaleGroup = d.u64()
v.Start = d.str()
v.End = d.str()
v.EndDelayMinutes = d.u64()
v.EventIndex = d.u64()
return v
}
func (e *encoder) hubsetting(v HubSetting) {
e.u64(v.Slot)
e.u64(v.ProgressType)
e.count(len(v.EventUIDs))
for _, item := range v.EventUIDs {
e.u64(item)
}
}
func (d *decoder) hubsetting() HubSetting {
var v HubSetting
v.Slot = d.u64()
v.ProgressType = d.u64()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.EventUIDs = append(v.EventUIDs, d.u64())
}
return v
}
func (e *encoder) eventhub(v EventHub) {
e.u64(v.UID)
e.u64(v.HubID)
e.str(v.Start)
e.str(v.PlayEnd)
e.str(v.End)
e.count(len(v.Settings))
for _, item := range v.Settings {
e.hubsetting(item)
}
}
func (d *decoder) eventhub() EventHub {
var v EventHub
v.UID = d.u64()
v.HubID = d.u64()
v.Start = d.str()
v.PlayEnd = d.str()
v.End = d.str()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Settings = append(v.Settings, d.hubsetting())
}
return v
}
func (e *encoder) minigamehub(v MiniGameHub) {
e.u64(v.Slot)
e.u64(v.EventUID)
e.u64(v.ProgressType)
}
func (d *decoder) minigamehub() MiniGameHub {
var v MiniGameHub
v.Slot = d.u64()
v.EventUID = d.u64()
v.ProgressType = d.u64()
return v
}
func (e *encoder) regularseason(v schedule.RegularSeason) {
e.u64(v.ContentID)
e.u64(v.Season)
}
func (d *decoder) regularseason() schedule.RegularSeason {
var v schedule.RegularSeason
v.ContentID = d.u64()
v.Season = d.u64()
return v
}
+115
View File
@@ -0,0 +1,115 @@
// Package calendar reads the project's independently distributed calendars.
package calendar
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gacha"
"bd2server/internal/server/schedule"
)
type Manifest struct {
SchemaVersion int
Revision string
GameVersion string
GameDataVersion string
Events []Event
Gacha []Gacha
StepUps []Gacha
Regular *Regular
MonsterHunt *MonsterHunt
CashProducts []CashProduct
EventHubs []EventHub
MiniGameHubs []MiniGameHub
}
type Event struct {
UID uint64
Type uint64
ID uint64
SubID uint64
Start string
End string
}
type Gacha struct {
GroupID uint64
Start string
End string
FreeCountBonus bool
CashCountBonus bool
}
type Season struct {
ID uint64
Start string
End string
RankRewardGroupID uint64
Error bool
Return bool
}
type Content struct {
ID uint64
Current Season
Next Season
}
type Regular struct {
CalculateMilliseconds uint64
Contents []Content
Regular []schedule.RegularSeason
}
type Hunt struct {
Season Season
HuntID uint64
InfoOpenDay uint64
CalculateEndAt string
ErrorFlag bool
IndependentFlag bool
RankRewardGroupID uint64
CostumeBanIDs []uint64
BurstBanIDs []uint64
}
type HuntHistory struct {
Season uint64
HuntID uint64
ErrorFlag bool
Hidden bool
}
type MonsterHunt struct {
Seasons []Hunt
StartRegularSeason uint64
History []HuntHistory
}
type CashProduct struct {
GroupID uint64
ProductID uint64
SaleGroup uint64
Start string
End string
EndDelayMinutes uint64
EventIndex uint64
}
type HubSetting struct {
Slot uint64
ProgressType uint64
EventUIDs []uint64
}
type EventHub struct {
UID uint64
HubID uint64
Start string
PlayEnd string
End string
Settings []HubSetting
}
type MiniGameHub struct {
Slot uint64
EventUID uint64
ProgressType uint64
}
type Set struct {
Events []events.Schedule
GachaSeed *gacha.ScheduleSeed
RegularService *schedule.Service
MonsterHunt *MonsterHunt
CashProducts []CashProduct
Revisions []string
EventHubs []EventHub
MiniGameHubs []MiniGameHub
}
+96
View File
@@ -0,0 +1,96 @@
package commerce
import (
"crypto/sha256"
"encoding/json"
"fmt"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type attendanceHandler interface {
Handle(string, []byte) (int, []byte, bool, error)
}
// AttendanceHandler preserves the original event progress response and adds
// subscription grants in extension fields understood by the commerce plugin.
// All operations execute inside the transport's account transaction.
type AttendanceHandler struct {
Events attendanceHandler
Economy *EntitlementEconomy
LoginPasses *LoginPasses
Store stateio.Store
}
type attendanceReceipt struct {
Digest string `json:"digest"`
Bundle []byte `json:"bundle"`
}
func (h AttendanceHandler) Handle(path string, request []byte) (int, []byte, bool, error) {
return h.HandleSession(path, request, "")
}
func (h AttendanceHandler) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
if path != "/Attendance" {
return 0, nil, false, nil
}
if session == "" || h.Events == nil || h.Economy == nil || h.Store == nil {
return 0, nil, true, fmt.Errorf("commerce: attendance dependencies/session unavailable")
}
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 {
return 0, nil, true, fmt.Errorf("commerce: missing attendance sequence")
}
code, response, handled, err := h.Events.Handle(path, request)
if err != nil || !handled {
return code, response, handled, err
}
key := fmt.Sprintf("commerce_attendance:%x:%d", sha256.Sum256([]byte(session)), seq)
digest := fmt.Sprintf("%x", sha256.Sum256(request))
var receipt attendanceReceipt
previous, err := h.Store.Load(key)
if err != nil {
return code, nil, true, err
}
var loginBundle []byte
if h.LoginPasses != nil {
var infos [][]byte
loginBundle, infos, err = h.LoginPasses.ClaimAndInfo(key)
if err != nil {
return code, nil, true, err
}
for _, info := range infos {
response = wire.AppendBytes(response, 6, info)
}
}
if previous != nil {
if err = json.Unmarshal(previous, &receipt); err != nil || receipt.Digest != digest {
return code, nil, true, fmt.Errorf("commerce: conflicting attendance replay")
}
} else {
receipt.Digest = digest
receipt.Bundle, err = h.Economy.ClaimSubscriptions(key)
if err != nil {
return code, nil, true, err
}
receipt.Bundle = append(receipt.Bundle, loginBundle...)
raw, err := json.Marshal(receipt)
if err != nil {
return code, nil, true, err
}
if err = h.Store.Save(key, raw); err != nil {
return code, nil, true, err
}
}
response, err = h.Economy.MergeAttendance(response)
if err != nil {
return code, nil, true, err
}
if len(receipt.Bundle) != 0 {
response = wire.AppendBytes(response, 1001, receipt.Bundle)
response = wire.AppendString(response, 1002, key)
}
return code, response, true, nil
}
@@ -0,0 +1,101 @@
package commerce
import (
"bytes"
"testing"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
type originalAttendance struct{}
func (originalAttendance) Handle(path string, _ []byte) (int, []byte, bool, error) {
return 0, wire.AppendVarint(nil, 55, 123), path == "/Attendance", nil
}
func TestAttendanceExtensionReplaysRewardAndPreservesNativeResponse(t *testing.T) {
e, _, base, _, now := entitlementFixture(t)
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
t.Fatal(err)
}
*now = now.Add(24 * time.Hour)
h := AttendanceHandler{Events: originalAttendance{}, Economy: e, Store: e.store}
request := wire.AppendVarint(nil, 1, 1)
_, response, handled, err := h.HandleSession("/Attendance", request, "session")
if err != nil || !handled {
t.Fatal(err)
}
if v, ok, _ := wire.Varint(response, 55); !ok || v != 123 {
t.Fatal("native response lost")
}
bundle, ok, err := wire.Bytes(response, 1001)
if err != nil || !ok || len(bundle) == 0 {
t.Fatal("reward extension missing")
}
if receipt, ok, _ := wire.Bytes(response, 1002); !ok || len(receipt) == 0 {
t.Fatal("receipt extension missing")
}
calls := base.Calls
_, replay, _, err := h.HandleSession("/Attendance", request, "session")
if err != nil || !bytes.Equal(response, replay) || base.Calls != calls {
t.Fatal("retry lost reward or granted twice", err)
}
_, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "session")
if err != nil {
t.Fatal(err)
}
if _, ok, _ := wire.Bytes(next, 1001); ok || base.Calls != calls {
t.Fatal("same day duplicate grant")
}
_, _, _, err = h.HandleSession("/Attendance", append(request, wire.AppendVarint(nil, 2, 1)...), "session")
if err == nil {
t.Fatal("conflicting sequence accepted")
}
}
func TestAttendanceCombinesLoginPassAndSubscriptionWithoutDuplicateClaims(t *testing.T) {
e, items, base, _, now := entitlementFixture(t)
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
t.Fatal(err)
}
*now = now.Add(24 * time.Hour)
design := &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{
10: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 3, Count: 11}, Premium: gamedata.Reward{Type: 3, Count: 22}}},
}}
passes, err := NewLoginPasses(e.store, design, e, items, func(group uint64) bool { return group == 10 })
if err != nil {
t.Fatal(err)
}
passes.SetClock(func() time.Time { return *now }, 0)
h := AttendanceHandler{Events: originalAttendance{}, Economy: e, LoginPasses: passes, Store: e.store}
calls := base.Calls
request := wire.AppendVarint(nil, 1, 1)
_, response, _, err := h.HandleSession("/Attendance", request, "combined-session")
if err != nil || base.Calls != calls+2 {
t.Fatalf("daily claims not combined: calls=%d err=%v", base.Calls-calls, err)
}
info, ok, err := wire.Bytes(response, 6)
if err != nil || !ok {
t.Fatal("login-pass metadata lost", err)
}
if group, _, _ := wire.Varint(info, 1); group != 10 {
t.Fatal("wrong login-pass group")
}
bundle, ok, err := wire.Bytes(response, 1001)
if err != nil || !ok || len(bundle) == 0 {
t.Fatal("combined reward envelope missing", err)
}
_, replay, _, err := h.HandleSession("/Attendance", request, "combined-session")
if err != nil || !bytes.Equal(replay, response) || base.Calls != calls+2 {
t.Fatal("retry changed the envelope or repeated claims", err)
}
_, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "combined-session")
if err != nil || base.Calls != calls+2 {
t.Fatal("fresh request claimed the same day again", err)
}
if _, ok, _ := wire.Bytes(next, 1001); ok {
t.Fatal("fresh request replayed old rewards")
}
}
+202
View File
@@ -0,0 +1,202 @@
// Package commerce exposes server-authoritative prices for SDK cash goods.
package commerce
import (
"fmt"
"math"
"sort"
"bd2server/internal/server/gameconfig"
"bd2server/internal/server/gamedata"
)
type Product struct {
SKU string `json:"sku"`
GroupID uint64 `json:"group_id"`
ProductID uint64 `json:"product_id"`
SaleGroup uint64 `json:"sale_group"`
ItemType uint64 `json:"item_type"`
Amount uint64 `json:"amount"`
Recharge bool `json:"recharge"`
Enabled bool `json:"enabled"`
Currency string `json:"currency"`
Cost uint64 `json:"cost"`
PaidDiamondPrice uint64 `json:"paid_diamond_price"`
}
type Manifest struct {
SchemaVersion int `json:"schema_version"`
GameVersion string `json:"game_version"`
Products []Product `json:"products"`
}
type Catalog struct {
version string
designs map[gamedata.CashProductKey]gamedata.CashProductDesign
quotes map[gamedata.CashProductKey]Product
manifest []Product
}
func NewCatalog(version string, design *gamedata.CashCatalog, cfg gameconfig.PurchasesConfig) (*Catalog, error) {
validation := gameconfig.Default()
validation.Purchases = cfg
if err := validation.Validate(); err != nil {
return nil, err
}
if version == "" || design == nil {
return nil, fmt.Errorf("commerce: missing cash catalog/version")
}
c := &Catalog{version: version, designs: map[gamedata.CashProductKey]gamedata.CashProductDesign{}, quotes: map[gamedata.CashProductKey]Product{}}
// Select the smallest regular recharge amount; its cash/diamond ratio is
// the conservative conversion baseline. GameData provides both quantities.
var baseAmount, basePrice uint64
for _, p := range design.Products {
if !p.Recharge || p.PriceType != 1 {
continue
}
if p.NominalPaidDiamonds == 0 || p.PriceCount == 0 {
return nil, fmt.Errorf("commerce: invalid recharge baseline")
}
if baseAmount == 0 || p.NominalPaidDiamonds < baseAmount || p.NominalPaidDiamonds == baseAmount && p.PriceCount > basePrice {
baseAmount = p.NominalPaidDiamonds
basePrice = p.PriceCount
}
}
if baseAmount == 0 {
return nil, fmt.Errorf("commerce: no regular diamond recharge conversion baseline")
}
for _, p := range design.Products {
if _, exists := c.designs[p.Key]; exists {
return nil, fmt.Errorf("commerce: duplicate product %+v", p.Key)
}
c.designs[p.Key] = p
if p.PriceType != 1 {
continue
} // native wallet goods already use their own path
price, err := ceilRatio(p.PriceCount, baseAmount, basePrice)
if err != nil {
return nil, err
}
q := Product{GroupID: p.Key.GroupID, ProductID: p.Key.ProductID, SaleGroup: p.Key.SaleGroup, Recharge: p.Recharge, Enabled: true, Currency: "paid_diamonds", ItemType: 2, Amount: price, Cost: price, PaidDiamondPrice: price}
if p.Recharge {
switch cfg.DiamondRecharge.Currency {
case "free":
q.Currency = "free"
q.ItemType = 0
q.Amount = 0
case "gold":
q.Currency = "gold"
q.ItemType = 4
q.Amount, err = boundedProduct(p.NominalPaidDiamonds, uint64(cfg.DiamondRecharge.GoldPerPaidDiamond))
case "diamonds":
q.Currency = "diamonds"
q.ItemType = 3
q.Amount, err = boundedProduct(p.NominalPaidDiamonds, uint64(cfg.DiamondRecharge.DiamondsPerPaidDiamond))
case "ban", "":
q.Currency = "ban"
q.Enabled = false
q.ItemType = 0
q.Amount = 0
}
if err != nil {
return nil, fmt.Errorf("commerce: recharge %+v: %w", p.Key, err)
}
q.Cost = q.Amount
}
aliases := []string{p.GoogleSKU}
if p.AppleSKU != p.GoogleSKU {
aliases = append(aliases, p.AppleSKU)
}
for _, sku := range aliases {
if sku == "" {
continue
}
q.SKU = sku
c.manifest = append(c.manifest, q)
}
q.SKU = p.GoogleSKU
if q.SKU == "" {
q.SKU = p.AppleSKU
}
if q.SKU == "" {
return nil, fmt.Errorf("commerce: cash product has no SKU")
}
c.quotes[p.Key] = q
}
sort.Slice(c.manifest, func(i, j int) bool {
a, b := c.manifest[i], c.manifest[j]
if a.GroupID != b.GroupID {
return a.GroupID < b.GroupID
}
if a.ProductID != b.ProductID {
return a.ProductID < b.ProductID
}
if a.SaleGroup != b.SaleGroup {
return a.SaleGroup < b.SaleGroup
}
return a.SKU < b.SKU
})
return c, nil
}
func ceilRatio(value, numerator, denominator uint64) (uint64, error) {
if value == 0 || numerator == 0 || denominator == 0 || value > math.MaxUint64/numerator {
return 0, fmt.Errorf("commerce: invalid or overflowing monetary conversion")
}
n := value * numerator
result := n / denominator
if n%denominator != 0 {
result++
}
if result == 0 || result > math.MaxInt32 {
return 0, fmt.Errorf("commerce: price outside supported wallet range")
}
return result, nil
}
func boundedProduct(a, b uint64) (uint64, error) {
if a == 0 || b == 0 || a > math.MaxInt32/b {
return 0, fmt.Errorf("commerce: purchase cost exceeds supported wallet range")
}
return a * b, nil
}
func (c *Catalog) Manifest() Manifest {
return Manifest{SchemaVersion: 1, GameVersion: c.version, Products: append([]Product{}, c.manifest...)}
}
func (c *Catalog) Design(key gamedata.CashProductKey) (gamedata.CashProductDesign, bool) {
p, ok := c.designs[key]
return p, ok
}
func (c *Catalog) Designs() []gamedata.CashProductDesign {
out := make([]gamedata.CashProductDesign, 0, len(c.designs))
for _, p := range c.designs {
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool {
a, b := out[i].Key, out[j].Key
if a.GroupID != b.GroupID {
return a.GroupID < b.GroupID
}
if a.ProductID != b.ProductID {
return a.ProductID < b.ProductID
}
return a.SaleGroup < b.SaleGroup
})
return out
}
func (c *Catalog) Quote(key gamedata.CashProductKey, count uint64) (Product, error) {
p, ok := c.quotes[key]
if !ok {
return Product{}, fmt.Errorf("commerce: unknown SDK cash product %+v", key)
}
if !p.Enabled {
return Product{}, fmt.Errorf("commerce: paid-diamond recharge is disabled")
}
if count == 0 || count > math.MaxInt32 {
return Product{}, fmt.Errorf("commerce: invalid purchase count")
}
if p.Amount != 0 {
cost, err := boundedProduct(p.Amount, count)
if err != nil {
return Product{}, err
}
p.Cost = cost
}
return p, nil
}
@@ -0,0 +1,95 @@
package commerce
import (
"bd2server/internal/server/gameconfig"
"bd2server/internal/server/gamedata"
"math"
"testing"
)
func testDesign() *gamedata.CashCatalog {
return &gamedata.CashCatalog{Products: []gamedata.CashProductDesign{
{Key: gamedata.CashProductKey{GroupID: 1, ProductID: 1, SaleGroup: 1}, GoogleSKU: "recharge", AppleSKU: "recharge-ios", PriceType: 1, PriceCount: 2200, Recharge: true, NominalPaidDiamonds: 100},
{Key: gamedata.CashProductKey{GroupID: 1, ProductID: 2, SaleGroup: 1}, GoogleSKU: "recharge", AppleSKU: "recharge-ios", PriceType: 1, PriceCount: 2200, Recharge: true, NominalPaidDiamonds: 100},
{Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, GoogleSKU: "skin", AppleSKU: "skin-ios", PriceType: 1, PriceCount: 5501},
{Key: gamedata.CashProductKey{GroupID: 3, ProductID: 1}, GoogleSKU: "native", PriceType: 2, PriceCount: 100},
}}
}
func TestCashPricesAliasesAndIsolation(t *testing.T) {
for _, currency := range []string{"free", "gold", "diamonds", "ban", ""} {
cfg := gameconfig.Default().Purchases
cfg.DiamondRecharge.Currency = currency
c, err := NewCatalog("test", testDesign(), cfg)
if err != nil {
t.Fatal(err)
}
m := c.Manifest()
if m.SchemaVersion != 1 || len(m.Products) != 6 {
t.Fatalf("manifest %+v", m)
}
skin, err := c.Quote(gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, 2)
if err != nil || skin.Cost != 502 || skin.ItemType != 2 || skin.Currency != "paid_diamonds" {
t.Fatalf("ordinary goods affected by %q: %+v %v", currency, skin, err)
}
recharge, err := c.Quote(gamedata.CashProductKey{GroupID: 1, ProductID: 1, SaleGroup: 1}, 1)
if currency == "ban" || currency == "" {
if err == nil {
t.Fatal("disabled recharge accepted")
}
continue
}
if err != nil {
t.Fatal(err)
}
want := uint64(0)
if currency == "gold" {
want = 100000
}
if currency == "diamonds" {
want = 100
}
if recharge.Cost != want {
t.Fatalf("currency %s cost=%d", currency, recharge.Cost)
}
second, _ := c.Quote(gamedata.CashProductKey{GroupID: 1, ProductID: 2, SaleGroup: 1}, 1)
if second.Cost != recharge.Cost {
t.Fatal("first purchase incorrectly doubled cost")
}
m.Products[0].SKU = "changed"
if c.Manifest().Products[0].SKU == "changed" {
t.Fatal("manifest mutable through caller")
}
if _, err = c.Quote(gamedata.CashProductKey{GroupID: 3, ProductID: 1}, 1); err == nil {
t.Fatal("native wallet item intercepted")
}
}
}
func TestMonetaryBoundsAndBaseline(t *testing.T) {
for _, v := range [][3]uint64{{math.MaxUint64, 2, 1}, {1, 0, 1}, {1, 1, 0}, {math.MaxInt32 + 1, 1, 1}} {
if _, err := ceilRatio(v[0], v[1], v[2]); err == nil {
t.Fatalf("accepted %v", v)
}
}
d := testDesign()
d.Products[0].PriceCount = 2000
d.Products[1].PriceCount = 2000
c, err := NewCatalog("test", d, gameconfig.Default().Purchases)
if err != nil {
t.Fatal(err)
}
p, _ := c.Quote(d.Products[2].Key, 1)
if p.Cost != 276 {
t.Fatalf("baseline not derived dynamically: %+v", p)
}
if _, err = c.Quote(d.Products[2].Key, math.MaxInt32); err == nil {
t.Fatal("overflow accepted")
}
if _, err = c.Quote(d.Products[2].Key, 0); err == nil {
t.Fatal("zero purchase accepted")
}
d.Products[0].Recharge = false
d.Products[1].Recharge = false
if _, err = NewCatalog("test", d, gameconfig.Default().Purchases); err == nil {
t.Fatal("missing baseline accepted")
}
}
@@ -0,0 +1,186 @@
package commerce
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"encoding/json"
"fmt"
"sort"
"sync"
"time"
)
type ClearPackageInventory interface{ All() []player.Item }
type clearClaim struct{ Kind, GroupID, TicketID, TargetID, Level uint64 }
type clearClaimReceipt struct {
Claim clearClaim `json:"claim"`
Response []byte `json:"response"`
}
type ClearPackages struct {
mu sync.Mutex
store stateio.Store
economy Economy
items ClearPackageInventory
design map[clearClaim]gamedata.ClearPackageRewardDesign
packCleared func(uint64, uint64) bool
towerCleared func(uint64, uint64) bool
now func() time.Time
}
func NewClearPackages(store stateio.Store, design *gamedata.ClearPackageCatalog, economy Economy, items ClearPackageInventory) (*ClearPackages, error) {
if store == nil || design == nil || economy == nil || items == nil {
return nil, fmt.Errorf("commerce: invalid clear package dependencies")
}
s := &ClearPackages{store: store, economy: economy, items: items, design: map[clearClaim]gamedata.ClearPackageRewardDesign{}, now: time.Now}
for _, r := range design.Rewards {
k := clearClaim{r.Kind, r.GroupID, r.TicketID, r.TargetID, r.Level}
if _, ok := s.design[k]; ok {
return nil, fmt.Errorf("commerce: duplicate clear reward")
}
s.design[k] = r
}
_, err := s.load()
return s, err
}
func (s *ClearPackages) AttachProgress(pack, tower func(uint64, uint64) bool) {
s.packCleared = pack
s.towerCleared = tower
}
func (s *ClearPackages) load() (map[string]clearClaimReceipt, error) {
v := map[string]clearClaimReceipt{}
raw, err := s.store.Load("commerce_clear_claims")
if err != nil || raw == nil {
return v, err
}
err = json.Unmarshal(raw, &v)
if err == nil && v == nil {
err = fmt.Errorf("commerce: invalid clear claim state")
}
if err == nil {
for identity, receipt := range v {
if identity != clearClaimID(receipt.Claim) || len(receipt.Response) == 0 {
err = fmt.Errorf("commerce: invalid saved clear claim")
break
}
if _, ok := s.design[receipt.Claim]; !ok {
err = fmt.Errorf("commerce: unknown saved clear claim")
break
}
}
}
return v, err
}
func clearClaimID(c clearClaim) string {
return fmt.Sprintf("clear-package:%d:%d:%d:%d:%d", c.Kind, c.GroupID, c.TicketID, c.TargetID, c.Level)
}
func (s *ClearPackages) entitled(ticket uint64) bool {
for _, item := range s.items.All() {
if item.Type == 19 && item.ID == ticket && item.Count > 0 && (item.ExpiryTime == 0 || item.ExpiryTime > uint64(s.now().UnixMilli())) {
return true
}
}
return false
}
func (s *ClearPackages) Handle(path string, request []byte) (int, []byte, bool, error) {
return s.HandleSession(path, request, "")
}
func (s *ClearPackages) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
if path != "/ClearPackageReward" {
return 0, nil, false, nil
}
seq, ok, err := wire.Varint(request, 1)
if err != nil || !ok || seq == 0 {
return 286, nil, true, fmt.Errorf("commerce: invalid clear claim sequence")
}
kind, _, err := wire.Varint(request, 2)
if err != nil || kind > 1 {
return 286, nil, true, fmt.Errorf("commerce: invalid clear claim type")
}
var nested []byte
err = wire.Walk(request, func(f wire.Field) error {
if f.Number == 3 || f.Number == 4 {
if int(f.Number) != int(kind)+3 || f.Type != 2 || nested != nil {
return fmt.Errorf("commerce: invalid clear claim row")
}
nested = append([]byte(nil), f.Value...)
}
return nil
})
if err != nil || nested == nil {
return 286, nil, true, fmt.Errorf("commerce: missing clear claim row")
}
var values [4]uint64
for i := range values {
values[i], _, err = wire.Varint(nested, i+1)
if err != nil {
return 286, nil, true, err
}
}
claim := clearClaim{kind, values[0], values[1], values[2], values[3]}
d, ok := s.design[claim]
if !ok {
return 286, nil, true, fmt.Errorf("commerce: unknown clear reward")
}
s.mu.Lock()
defer s.mu.Unlock()
v, err := s.load()
if err != nil {
return 286, nil, true, err
}
identity := clearClaimID(claim)
if receipt, ok := v[identity]; ok {
return 286, append([]byte(nil), receipt.Response...), true, nil
}
if d.Type == 1 && !s.entitled(d.TicketID) {
return 286, nil, true, fmt.Errorf("commerce: clear reward premium ticket required")
}
proof := s.packCleared
if kind == 1 {
proof = s.towerCleared
}
if proof == nil || !proof(d.TargetID, d.Level) {
return 286, nil, true, fmt.Errorf("commerce: clear reward progression incomplete")
}
bundle, err := s.economy.Apply(identity, nil, []gamedata.Reward{{Type: 9, ID: d.RandomBoxID, Count: 1}})
if err != nil {
return 286, nil, true, err
}
response := wire.AppendBytes(nil, 1, bundle)
v[identity] = clearClaimReceipt{Claim: claim, Response: response}
raw, err := json.Marshal(v)
if err == nil {
err = s.store.Save("commerce_clear_claims", raw)
}
return 286, response, true, err
}
// RewardDBInfos is attached to PackInfoResponse fields 3 and 4 so reconnects
// restore claimed reward buttons from server state.
func (s *ClearPackages) RewardDBInfos() (pack, evil [][]byte, err error) {
s.mu.Lock()
defer s.mu.Unlock()
v, err := s.load()
if err != nil {
return nil, nil, err
}
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
c := v[k].Claim
b := wire.AppendVarint(nil, 1, c.GroupID)
b = wire.AppendVarint(b, 2, c.TicketID)
b = wire.AppendVarint(b, 3, c.TargetID)
b = wire.AppendVarint(b, 4, c.Level)
if c.Kind == 0 {
pack = append(pack, b)
} else {
evil = append(evil, b)
}
}
return pack, evil, nil
}
@@ -0,0 +1,61 @@
package commerce
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"testing"
)
type clearInventory struct{ items []player.Item }
func (i *clearInventory) All() []player.Item { return i.items }
func clearRequest(kind, ticket uint64) []byte {
b := wire.AppendVarint(nil, 1, 1)
b = wire.AppendVarint(b, 2, kind)
row := wire.AppendVarint(nil, 1, 10)
row = wire.AppendVarint(row, 2, ticket)
row = wire.AppendVarint(row, 3, 2)
row = wire.AppendVarint(row, 4, 0)
return wire.AppendBytes(b, int(kind)+3, row)
}
func TestClearPackageRequiresServerProgressAndPremiumEntitlement(t *testing.T) {
store := stateio.NewMemory()
eco := &purchaseEconomy{}
items := &clearInventory{}
design := &gamedata.ClearPackageCatalog{Rewards: []gamedata.ClearPackageRewardDesign{{Kind: 0, GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 100, Type: 0}, {Kind: 0, GroupID: 10, TicketID: 77, TargetID: 2, RandomBoxID: 101, Type: 1}, {Kind: 1, GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 102, Type: 0}}}
s, err := NewClearPackages(store, design, eco, items)
if err != nil {
t.Fatal(err)
}
if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 12)); err == nil || eco.calls != 0 {
t.Fatal("client spoofed clear progress")
}
s.AttachProgress(func(pack, level uint64) bool { return pack == 2 && level == 0 }, nil)
if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 77)); err == nil {
t.Fatal("unpaid premium claim accepted")
}
code, _, ok, err := s.Handle("/ClearPackageReward", clearRequest(0, 12))
if err != nil || !ok || code != 286 || eco.rewards[0].ID != 100 {
t.Fatal(code, err)
}
items.items = []player.Item{{Type: 19, ID: 77, Count: 1}}
if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 77)); err != nil {
t.Fatal(err)
}
reloaded, err := NewClearPackages(store, design, eco, items)
if err != nil {
t.Fatal(err)
}
if _, _, _, err = reloaded.Handle("/ClearPackageReward", clearRequest(0, 77)); err != nil || eco.calls != 2 {
t.Fatal("restart duplicated grant", err, eco.calls)
}
p, e, err := reloaded.RewardDBInfos()
if err != nil || len(p) != 2 || len(e) != 0 {
t.Fatal(p, e, err)
}
if _, _, _, err = reloaded.Handle("/ClearPackageReward", clearRequest(1, 12)); err == nil {
t.Fatal("unimplemented evil progress granted reward")
}
}
+323
View File
@@ -0,0 +1,323 @@
package commerce
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"sync"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type grantedResolver interface {
ResolveGranted([]gamedata.BattleReward) ([]gamedata.BattleReward, error)
}
type entitlementReceipt struct {
Definition []byte `json:"definition"`
Bundle []byte `json:"bundle"`
}
type cashSubscription struct {
Start, Expiry int64
Claimed uint64
LastDay string
}
type entitlementState struct {
Receipts map[string]entitlementReceipt `json:"receipts"`
Subscriptions map[string]cashSubscription `json:"subscriptions"`
}
type EntitlementEconomy struct {
mu sync.Mutex
store stateio.Store
base Economy
graph grantedResolver
items *player.Inventory
design *gamedata.CashEntitlementDesign
now func() time.Time
resetSeconds int64
}
func NewEntitlementEconomy(store stateio.Store, base Economy, graph grantedResolver, items *player.Inventory, design *gamedata.CashEntitlementDesign) (*EntitlementEconomy, error) {
if store == nil || base == nil || graph == nil || items == nil || design == nil {
return nil, fmt.Errorf("commerce: missing entitlement dependency")
}
e := &EntitlementEconomy{store: store, base: base, graph: graph, items: items, design: design, now: time.Now}
_, err := e.load()
return e, err
}
func (e *EntitlementEconomy) SetClock(now func() time.Time, resetSeconds int64) {
e.now = now
e.resetSeconds = resetSeconds
}
func (e *EntitlementEconomy) day() string {
return e.now().UTC().Add(-time.Duration(e.resetSeconds) * time.Second).Format("2006-01-02")
}
func (e *EntitlementEconomy) load() (entitlementState, error) {
s := entitlementState{Receipts: map[string]entitlementReceipt{}, Subscriptions: map[string]cashSubscription{}}
raw, err := e.store.Load("commerce_entitlements")
if err != nil || raw == nil {
return s, err
}
if err = stateio.RequireExactJSONObject(raw, "receipts", "subscriptions"); err != nil {
return s, err
}
if err = json.Unmarshal(raw, &s); err != nil {
return s, err
}
if s.Receipts == nil || s.Subscriptions == nil {
return s, fmt.Errorf("commerce: malformed entitlement state")
}
for id, r := range s.Receipts {
if id == "" || len(r.Definition) != sha256.Size {
return s, fmt.Errorf("commerce: malformed entitlement receipt")
}
}
for id, sub := range s.Subscriptions {
ticket, err := strconv.ParseUint(id, 10, 64)
if err != nil || len(e.design.Attendance[ticket]) == 0 || sub.Start <= 0 || sub.Expiry != 0 && sub.Expiry < sub.Start || sub.Claimed == 0 || sub.LastDay == "" {
return s, fmt.Errorf("commerce: malformed subscription")
}
}
return s, nil
}
func (e *EntitlementEconomy) save(s entitlementState) error {
b, err := json.Marshal(s)
if err != nil {
return err
}
return e.store.Save("commerce_entitlements", b)
}
func (e *EntitlementEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
e.mu.Lock()
defer e.mu.Unlock()
return e.apply(identity, costs, rewards)
}
func (e *EntitlementEconomy) apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
if identity == "" {
return nil, fmt.Errorf("commerce: missing entitlement identity")
}
definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards})
digest := sha256.Sum256(definition)
s, err := e.load()
if err != nil {
return nil, err
}
if r, ok := s.Receipts[identity]; ok {
if !bytes.Equal(r.Definition, digest[:]) {
return nil, fmt.Errorf("commerce: entitlement identity reused")
}
return append([]byte(nil), r.Bundle...), nil
}
input := make([]gamedata.BattleReward, len(rewards))
for i, r := range rewards {
input[i] = gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count}
}
leaves, err := e.graph.ResolveGranted(input)
if err != nil {
return nil, err
}
// Some product boxes include the first attendance reward (draw-ticket
// subscriptions); others include only the upfront paid currency. Add only
// the missing first-day components on activation, then mark row one claimed.
initial := append([]gamedata.BattleReward(nil), leaves...)
for _, r := range initial {
rows := e.design.Attendance[r.ID]
if r.Type != 19 || len(rows) == 0 {
continue
}
sub := s.Subscriptions[strconv.FormatUint(r.ID, 10)]
if sub.Start != 0 && (sub.Expiry == 0 || sub.Expiry > e.now().UnixMilli()) {
continue
}
first, resolveErr := e.graph.ResolveGranted([]gamedata.BattleReward{rows[0].Reward})
if resolveErr != nil {
return nil, resolveErr
}
available := map[[2]uint64]uint64{}
for _, leaf := range leaves {
k := [2]uint64{leaf.Type, leaf.ID}
available[k] += leaf.Count
}
for _, leaf := range first {
k := [2]uint64{leaf.Type, leaf.ID}
if available[k] < leaf.Count {
leaf.Count -= available[k]
leaves = append(leaves, leaf)
}
}
}
var regular []gamedata.Reward
var special []player.Item
for _, r := range leaves {
if r.Count == 0 || r.Count > math.MaxInt32 {
return nil, fmt.Errorf("commerce: invalid entitlement quantity")
}
switch {
case r.Type == 62:
if !e.design.AvatarSets[r.ID] {
return nil, fmt.Errorf("commerce: unknown avatar set %d", r.ID)
}
special = append(special, player.Item{Type: r.Type, ID: r.ID, Count: r.Count, TimeValue: uint64(e.now().UnixMilli())})
case r.Type == 19 && e.design.TicketTypes[r.ID] == 2:
if len(e.design.Attendance[r.ID]) == 0 {
return nil, fmt.Errorf("commerce: subscription reward schedule missing")
}
now := e.now().UnixMilli()
expiry := int64(e.items.ContentTicketExpiry(r.ID))
if expiry < now {
expiry = now
}
if r.Count > uint64((math.MaxInt64-expiry)/(30*86400000)) {
return nil, fmt.Errorf("commerce: subscription expiry overflow")
}
expiry += int64(r.Count) * 30 * 86400000
special = append(special, player.Item{Type: 19, ID: r.ID, Count: r.Count, ExpiryTime: uint64(expiry), TimeValue: uint64(e.now().UnixMilli())})
key := strconv.FormatUint(r.ID, 10)
sub := s.Subscriptions[key]
if sub.Start == 0 || sub.Expiry <= now {
sub = cashSubscription{Start: now, Claimed: 1, LastDay: e.day()}
}
sub.Expiry = expiry
s.Subscriptions[key] = sub
default:
regular = append(regular, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count})
if r.Type == 19 && e.design.TicketTypes[r.ID] == 3 && len(e.design.Attendance[r.ID]) > 0 {
key := strconv.FormatUint(r.ID, 10)
if _, exists := s.Subscriptions[key]; !exists {
s.Subscriptions[key] = cashSubscription{Start: e.now().UnixMilli(), Claimed: 1, LastDay: e.day()}
}
}
}
}
bundle, err := e.base.Apply(identity+":base", costs, regular)
if err != nil {
return nil, err
}
items, err := e.items.GrantCommerceOnce(identity+":special", special)
if err != nil {
return nil, err
}
for _, i := range items {
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(i))
bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{ID: i.ID, Type: i.Type, Count: i.Count}))
}
s.Receipts[identity] = entitlementReceipt{Definition: append([]byte(nil), digest[:]...), Bundle: append([]byte(nil), bundle...)}
if err = e.save(s); err != nil {
return nil, err
}
return bundle, nil
}
// ClaimSubscriptions grants the next row once per server reset day. Missing
// login days are not retroactively claimed. First-row purchase rewards are
// already present in the product box, matching the client's first-row marker.
func (e *EntitlementEconomy) ClaimSubscriptions(identity string) ([]byte, error) {
e.mu.Lock()
defer e.mu.Unlock()
if identity == "" {
return nil, fmt.Errorf("commerce: missing attendance receipt identity")
}
s, err := e.load()
if err != nil {
return nil, err
}
receiptKey := "attendance-reply:" + identity
if receipt, ok := s.Receipts[receiptKey]; ok {
return append([]byte(nil), receipt.Bundle...), nil
}
keys := make([]string, 0, len(s.Subscriptions))
for k := range s.Subscriptions {
keys = append(keys, k)
}
sort.Strings(keys)
var bundle []byte
for _, key := range keys {
sub := s.Subscriptions[key]
ticket, _ := strconv.ParseUint(key, 10, 64)
rows := e.design.Attendance[ticket]
if sub.LastDay == e.day() || sub.Expiry != 0 && sub.Expiry <= e.now().UnixMilli() || sub.Expiry == 0 && sub.Claimed >= uint64(len(rows)) {
continue
}
index := sub.Claimed % uint64(len(rows))
r := rows[index]
grant, err := e.apply(fmt.Sprintf("commerce:attendance:%s:%s", key, e.day()), nil, []gamedata.Reward{{Type: r.Reward.Type, ID: r.Reward.ID, Count: r.Reward.Count}})
if err != nil {
return nil, err
}
bundle = append(bundle, grant...)
// apply persists its receipt; reload before saving progress so it survives.
latest, err := e.load()
if err != nil {
return nil, err
}
sub.Claimed++
sub.LastDay = e.day()
latest.Subscriptions[key] = sub
if err = e.save(latest); err != nil {
return nil, err
}
s = latest
}
latest, err := e.load()
if err != nil {
return nil, err
}
digest := sha256.Sum256([]byte(receiptKey))
latest.Receipts[receiptKey] = entitlementReceipt{Definition: append([]byte(nil), digest[:]...), Bundle: append([]byte(nil), bundle...)}
if err = e.save(latest); err != nil {
return nil, err
}
return bundle, nil
}
func (e *EntitlementEconomy) MergeAttendance(response []byte) ([]byte, error) {
e.mu.Lock()
defer e.mu.Unlock()
s, err := e.load()
if err != nil {
return nil, err
}
out := append([]byte(nil), response...)
keys := make([]string, 0, len(s.Subscriptions))
for k := range s.Subscriptions {
keys = append(keys, k)
}
sort.Strings(keys)
for _, key := range keys {
sub := s.Subscriptions[key]
ticket, _ := strconv.ParseUint(key, 10, 64)
if sub.Expiry != 0 {
b := wire.AppendVarint(nil, 1, ticket)
b = wire.AppendVarint(b, 2, uint64(sub.Start))
b = wire.AppendVarint(b, 3, uint64(sub.Expiry))
out = wire.AppendBytes(out, 3, b)
}
if typ := e.design.AttendanceTypes[ticket]; typ != 0 {
rewards := []byte{}
n := sub.Claimed
if n > uint64(len(e.design.Attendance[ticket])) {
n = uint64(len(e.design.Attendance[ticket]))
}
for i := uint64(1); i <= n; i++ {
rewards = wire.AppendVarint(rewards, 1, i)
}
entry := wire.AppendVarint(nil, 1, typ)
entry = wire.AppendBytes(entry, 2, rewards)
out = wire.AppendBytes(out, 7, entry)
} else {
for i := uint64(1); i <= sub.Claimed; i++ {
b := wire.AppendVarint(nil, 1, ticket)
b = wire.AppendVarint(b, 2, i)
b = wire.AppendVarint(b, 3, 1)
out = wire.AppendBytes(out, 4, b)
}
}
}
return out, nil
}
@@ -0,0 +1,138 @@
package commerce
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"testing"
"time"
)
type entitlementGraph struct{ Calls int }
func (g *entitlementGraph) ResolveGranted(r []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
g.Calls++
return r, nil
}
type entitlementBase struct {
Calls int
Rewards []gamedata.Reward
}
func (b *entitlementBase) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) {
b.Calls++
b.Rewards = append(b.Rewards, r...)
return wire.AppendVarint(nil, 7, 1), nil
}
func entitlementFixture(t *testing.T) (*EntitlementEconomy, *player.Inventory, *entitlementBase, *entitlementGraph, *time.Time) {
t.Helper()
store := stateio.NewMemory()
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
d := &gamedata.CashEntitlementDesign{AvatarSets: map[uint64]bool{10: true}, TicketTypes: map[uint64]uint64{38: 2, 99: 3}, Attendance: map[uint64][]gamedata.CashAttendanceReward{38: {{ID: 1, Reward: gamedata.BattleReward{Type: 3, Count: 1}}, {ID: 2, Reward: gamedata.BattleReward{Type: 3, Count: 5}}}, 99: {{ID: 1, Reward: gamedata.BattleReward{Type: 3, Count: 1}}, {ID: 2, Reward: gamedata.BattleReward{Type: 3, Count: 7}}}}, AttendanceTypes: map[uint64]uint64{38: 1}}
base := &entitlementBase{}
graph := &entitlementGraph{}
e, err := NewEntitlementEconomy(store, base, graph, items, d)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
e.SetClock(func() time.Time { return now }, 0)
return e, items, base, graph, &now
}
func TestEntitlementTypedSkinExpiryRetryAndRenewal(t *testing.T) {
e, items, base, graph, now := entitlementFixture(t)
rewards := []gamedata.Reward{{Type: 62, ID: 10, Count: 1}, {Type: 19, ID: 38, Count: 1}, {Type: 45, ID: 999, Count: 1}}
first, err := e.Apply("buy1", nil, rewards)
if err != nil {
t.Fatal(err)
}
if items.ContentTicketExpiry(38) != uint64(now.UnixMilli()+30*86400000) || len(base.Rewards) != 2 || base.Rewards[0].Type != 45 {
t.Fatal("expiry or prestige dispatch invalid")
}
again, err := e.Apply("buy1", nil, rewards)
if err != nil || !bytes.Equal(first, again) || graph.Calls != 2 || base.Calls != 1 {
t.Fatal("retry rerolled or double granted")
}
if _, err = e.Apply("buy1", nil, nil); err == nil {
t.Fatal("identity reuse accepted")
}
if _, err = e.Apply("invalid", nil, []gamedata.Reward{{Type: 62, ID: 888, Count: 1}}); err == nil || base.Calls != 1 {
t.Fatal("unknown skin partially applied")
}
if _, err = e.Apply("buy2", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
t.Fatal(err)
}
if items.ContentTicketExpiry(38) != uint64(now.UnixMilli()+60*86400000) {
t.Fatal("renewal lost prior period")
}
merged, err := e.MergeAttendance(nil)
if err != nil {
t.Fatal(err)
}
if _, ok, _ := wire.Bytes(merged, 3); !ok {
t.Fatal("subscription info missing")
}
if _, ok, _ := wire.Bytes(merged, 7); !ok {
t.Fatal("monthly reward map missing")
}
}
func TestSubscriptionDailyClaimResetExpiryAndPermanentCompletion(t *testing.T) {
e, _, base, _, now := entitlementFixture(t)
_, err := e.Apply("buy", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}, {Type: 19, ID: 99, Count: 1}})
if err != nil {
t.Fatal(err)
}
calls := base.Calls
if _, err = e.ClaimSubscriptions("login"); err != nil || base.Calls != calls {
t.Fatal("purchase day double claim")
}
*now = now.Add(24 * time.Hour)
if _, err = e.ClaimSubscriptions("login2"); err != nil || base.Calls != calls+2 {
t.Fatalf("daily claims %v calls%d", err, base.Calls)
}
calls = base.Calls
if _, err = e.ClaimSubscriptions("retry"); err != nil || base.Calls != calls {
t.Fatal("daily retry grants again")
}
*now = now.Add(31 * 24 * time.Hour)
if _, err = e.ClaimSubscriptions("expired"); err != nil || base.Calls != calls {
t.Fatal("expired or completed claim")
}
merged, err := e.MergeAttendance(nil)
if err != nil {
t.Fatal(err)
}
if _, ok, _ := wire.Bytes(merged, 4); !ok {
t.Fatal("permanent attendance markers missing")
}
}
func TestSubscriptionFirstRewardAndLostReply(t *testing.T) {
e, _, base, _, now := entitlementFixture(t)
_, err := e.Apply("covered", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}, {Type: 3, Count: 1}})
if err != nil {
t.Fatal(err)
}
if len(base.Rewards) != 1 || base.Rewards[0].Count != 1 {
t.Fatalf("first-day reward duplicated: %+v", base.Rewards)
}
*now = now.Add(24 * time.Hour)
first, err := e.ClaimSubscriptions("same-session-request")
if err != nil || len(first) == 0 {
t.Fatalf("claim %x %v", first, err)
}
calls := base.Calls
replay, err := e.ClaimSubscriptions("same-session-request")
if err != nil || !bytes.Equal(first, replay) || base.Calls != calls {
t.Fatal("lost reply not replayed exactly")
}
fresh, err := e.ClaimSubscriptions("new-request-same-day")
if err != nil || len(fresh) != 0 || base.Calls != calls {
t.Fatal("new same-day request repeated award")
}
}
@@ -0,0 +1,80 @@
package commerce
import (
"os"
"testing"
"bd2server/internal/server/gameconfig"
"bd2server/internal/server/gamedata"
)
// Opt in against the installed release; synthetic tests also run without it.
func TestInstalledCashCatalogAndRewardPrograms(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not set")
}
design, err := gamedata.LoadCashCatalog(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
catalog, err := NewCatalog("2.35.10", design, gameconfig.Default().Purchases)
if err != nil {
t.Fatal(err)
}
rewards, err := gamedata.LoadCashRewardResolver(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
entitlements, err := gamedata.LoadCashEntitlementDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
counts, recharge := 0, 0
for _, product := range design.Products {
if product.PriceType != 1 {
continue
}
counts++
if product.Recharge {
recharge++
}
quote, err := catalog.Quote(product.Key, 1)
if err != nil {
t.Fatalf("quote %+v: %v", product.Key, err)
}
if product.Recharge && (quote.ItemType != 0 || quote.Amount != 0) {
t.Fatalf("default recharge isn't free: %+v", quote)
}
if !product.Recharge && (quote.ItemType != 2 || quote.Amount == 0) {
t.Fatalf("cash goods aren't paid diamond: %+v", quote)
}
// Infinite preview products use the gacha delegate, whose selected
// costumes replace weighted/empty placeholder reward programs.
if product.Key.GroupID == 1100001 {
continue
}
leaves, err := rewards.ResolveGranted([]gamedata.BattleReward{{Type: 9, ID: product.RandomBoxID, Count: 1}})
if err != nil || len(leaves) == 0 {
t.Fatalf("product %+v: no reward program: %v", product.Key, err)
}
for _, leaf := range leaves {
if leaf.Type == 19 && entitlements.TicketTypes[leaf.ID] == 2 && len(entitlements.Attendance[leaf.ID]) == 0 {
t.Fatalf("monthly reward schedule missing: %d", leaf.ID)
}
if leaf.Type == 62 && !entitlements.AvatarSets[leaf.ID] {
t.Fatalf("avatar set missing: %d", leaf.ID)
}
}
}
if counts != 614 || recharge != 14 {
t.Fatalf("unexpected cash coverage: goods=%d recharge=%d", counts, recharge)
}
for _, key := range []gamedata.CashProductKey{{GroupID: 1100001, ProductID: 9100033}, {GroupID: 310001, ProductID: 507}, {GroupID: 310001, ProductID: 518}} {
quote, err := catalog.Quote(key, 1)
if err != nil || quote.Amount != 1000 || quote.ItemType != 2 {
t.Fatalf("sample %+v: %+v %v", key, quote, err)
}
}
t.Logf("Verified all %d cash products, %d recharge variants and %d platform aliases", counts, recharge, len(catalog.Manifest().Products))
}
+157
View File
@@ -0,0 +1,157 @@
package commerce
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"encoding/json"
"fmt"
"sort"
"sync"
"time"
)
type loginPassProgress struct {
Free, Premium uint64
LastDay string
}
type loginPassReceipt struct {
Bundle []byte
Rewarded map[uint64]bool
}
type loginPassState struct {
Progress map[uint64]loginPassProgress
Receipts map[string]loginPassReceipt
}
type LoginPasses struct {
mu sync.Mutex
store stateio.Store
design *gamedata.LoginPassCatalog
economy Economy
items ClearPackageInventory
available func(uint64) bool
now func() time.Time
resetSeconds int64
}
func NewLoginPasses(store stateio.Store, design *gamedata.LoginPassCatalog, economy Economy, items ClearPackageInventory, available func(uint64) bool) (*LoginPasses, error) {
if store == nil || design == nil || economy == nil || items == nil || available == nil {
return nil, fmt.Errorf("commerce: invalid login-pass dependencies")
}
s := &LoginPasses{store: store, design: design, economy: economy, items: items, available: available, now: time.Now}
_, err := s.load()
return s, err
}
func (s *LoginPasses) SetClock(now func() time.Time, resetSeconds int64) {
s.now = now
s.resetSeconds = resetSeconds
}
func (s *LoginPasses) load() (loginPassState, error) {
v := loginPassState{Progress: map[uint64]loginPassProgress{}, Receipts: map[string]loginPassReceipt{}}
raw, err := s.store.Load("commerce_login_passes")
if err != nil || raw == nil {
return v, err
}
if err = json.Unmarshal(raw, &v); err != nil {
return v, err
}
if v.Progress == nil || v.Receipts == nil {
return v, fmt.Errorf("commerce: invalid login-pass state")
}
for group, p := range v.Progress {
rows, ok := s.design.Groups[group]
if !ok || p.Free > uint64(len(rows)) || p.Premium > p.Free || p.Free > 0 && p.LastDay == "" {
return v, fmt.Errorf("commerce: invalid login-pass progress")
}
}
return v, nil
}
func (s *LoginPasses) paid(ticket uint64) bool {
for _, i := range s.items.All() {
if i.Type == 19 && i.ID == ticket && i.Count > 0 && (i.ExpiryTime == 0 || i.ExpiryTime > uint64(s.now().UnixMilli())) {
return true
}
}
return false
}
// ClaimAndInfo is called by AttendanceHandler inside the account transaction.
// Free progression advances once per observed reset day, and buying premium
// catches up the already earned rows without advancing the login day count.
func (s *LoginPasses) ClaimAndInfo(identity string) ([]byte, [][]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
if identity == "" {
return nil, nil, fmt.Errorf("commerce: missing login-pass identity")
}
v, err := s.load()
if err != nil {
return nil, nil, err
}
keys := make([]uint64, 0, len(s.design.Groups))
for group := range s.design.Groups {
keys = append(keys, group)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
r, seen := v.Receipts[identity]
if !seen {
r = loginPassReceipt{Rewarded: map[uint64]bool{}}
var rewards []gamedata.Reward
day := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second).Format("2006-01-02")
for _, group := range keys {
if !s.available(group) {
continue
}
rows := s.design.Groups[group]
if len(rows) == 0 {
continue
}
p := v.Progress[group]
if p.LastDay != day && p.Free < uint64(len(rows)) {
rewards = append(rewards, rows[p.Free].Free)
p.Free++
p.LastDay = day
r.Rewarded[group] = true
}
if s.paid(rows[0].TicketID) {
for p.Premium < p.Free {
rewards = append(rewards, rows[p.Premium].Premium)
p.Premium++
r.Rewarded[group] = true
}
}
v.Progress[group] = p
}
if len(rewards) > 0 {
r.Bundle, err = s.economy.Apply("login-pass:"+identity, nil, rewards)
if err != nil {
return nil, nil, err
}
}
v.Receipts[identity] = r
raw, err := json.Marshal(v)
if err != nil {
return nil, nil, err
}
if err = s.store.Save("commerce_login_passes", raw); err != nil {
return nil, nil, err
}
}
var infos [][]byte
for _, group := range keys {
if !s.available(group) {
continue
}
p := v.Progress[group]
if p.Free == 0 {
continue
}
b := wire.AppendVarint(nil, 1, group)
b = wire.AppendVarint(b, 2, p.Free)
if r.Rewarded[group] {
b = wire.AppendVarint(b, 3, 1)
}
infos = append(infos, b)
}
return append([]byte(nil), r.Bundle...), infos, nil
}
@@ -0,0 +1,68 @@
package commerce
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"testing"
"time"
)
type loginPassEconomy struct {
calls int
rewards [][]gamedata.Reward
}
func (e *loginPassEconomy) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) {
e.calls++
e.rewards = append(e.rewards, append([]gamedata.Reward(nil), r...))
return []byte{8, 1}, nil
}
func TestLoginPassFreeDaysPaidCatchupAndRestart(t *testing.T) {
d := &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{10: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 9, ID: 100, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 200, Count: 1}}, {ID: 2, TicketID: 77, Free: gamedata.Reward{Type: 9, ID: 101, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 201, Count: 1}}}, 20: {{ID: 1, TicketID: 88, Free: gamedata.Reward{Type: 9, ID: 300, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 400, Count: 1}}}}}
store := stateio.NewMemory()
eco := &loginPassEconomy{}
items := &clearInventory{}
available := func(group uint64) bool { return group == 10 }
s, err := NewLoginPasses(store, d, eco, items, available)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
s.SetClock(func() time.Time { return now }, 0)
bundle, infos, err := s.ClaimAndInfo("attendance1")
if err != nil || eco.calls != 1 || len(eco.rewards[0]) != 1 || eco.rewards[0][0].ID != 100 || len(infos) != 1 {
t.Fatal(err, eco.rewards, infos)
}
replay, _, err := s.ClaimAndInfo("attendance1")
if err != nil || !bytes.Equal(bundle, replay) || eco.calls != 1 {
t.Fatal("duplicate login grant", err)
}
_, infos, err = s.ClaimAndInfo("attendance2")
rewarded, _, _ := wire.Varint(infos[0], 3)
if err != nil || eco.calls != 1 || rewarded != 0 {
t.Fatal("same-day advanced")
}
items.items = []player.Item{{Type: 19, ID: 77, Count: 1}}
_, _, err = s.ClaimAndInfo("after-buy")
if err != nil || eco.calls != 2 || len(eco.rewards[1]) != 1 || eco.rewards[1][0].ID != 200 {
t.Fatal("premium catchup missing", err, eco.rewards)
}
now = now.Add(48 * time.Hour)
_, infos, err = s.ClaimAndInfo("next-login")
day, _, _ := wire.Varint(infos[0], 2)
if err != nil || day != 2 || eco.calls != 3 || len(eco.rewards[2]) != 2 {
t.Fatal("missed day incorrectly advanced", err, day)
}
s, err = NewLoginPasses(store, d, eco, items, available)
if err != nil {
t.Fatal(err)
}
s.SetClock(func() time.Time { return now }, 0)
_, _, err = s.ClaimAndInfo("restart")
if err != nil || eco.calls != 3 {
t.Fatal("restart advanced complete pass", err)
}
}
+39
View File
@@ -0,0 +1,39 @@
package commerce
import "bd2server/internal/server/wire"
type PackInfoHandler struct {
World attendanceHandler
Claims *ClearPackages
}
func (h PackInfoHandler) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/PackInfo" {
return 0, nil, false, nil
}
code, response, handled, err := h.World.Handle(path, request)
if err != nil || !handled {
return code, response, handled, err
}
pack, evil, err := h.Claims.RewardDBInfos()
if err != nil {
return code, nil, true, err
}
var result []byte
err = wire.Walk(response, func(field wire.Field) error {
if field.Number != 3 && field.Number != 4 {
result = append(result, response[field.Start:field.End]...)
}
return nil
})
if err != nil {
return code, nil, true, err
}
for _, info := range pack {
result = wire.AppendBytes(result, 3, info)
}
for _, info := range evil {
result = wire.AppendBytes(result, 4, info)
}
return code, result, true, nil
}
+695
View File
@@ -0,0 +1,695 @@
package commerce
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"sync"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/readonly"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
// Economy and the receipt store must participate in the caller's account
// transaction. A failed hook, delegate or save then rolls back all purchases.
type Economy interface {
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
}
type CountProvider interface{ PurchaseCountDBInfos() [][]byte }
type PurchaseDelegate func(gamedata.CashProductKey, []byte) ([]byte, bool, error)
type PurchaseHook func(string, gamedata.CashProductDesign, uint64) error
type purchaseReceipt struct {
Digest string `json:"digest"`
Response []byte `json:"response"`
}
type purchaseCount struct {
Count uint64 `json:"count"`
Period string `json:"period"`
Lifetime uint64 `json:"lifetime"`
}
type purchaseState struct {
Receipts map[string]purchaseReceipt `json:"receipts"`
Billing map[string]string `json:"billing"`
Counts map[string]purchaseCount `json:"counts"`
}
type Service struct {
mu sync.Mutex
catalog *Catalog
store stateio.Store
economy Economy
now func() time.Time
resetSeconds int64
delegate PurchaseDelegate
hook PurchaseHook
legacy CountProvider
shopProducts [][]byte
shopWindows map[gamedata.CashProductKey][2]uint64
predecessors map[gamedata.CashProductKey][]gamedata.CashProductKey
}
func NewService(catalog *Catalog, store stateio.Store, economy Economy) (*Service, error) {
if catalog == nil || store == nil || economy == nil {
return nil, fmt.Errorf("commerce: invalid service dependencies")
}
s := &Service{catalog: catalog, store: store, economy: economy, now: time.Now}
_, err := s.load()
return s, err
}
func (s *Service) AttachDelegate(d PurchaseDelegate) { s.delegate = d }
func (s *Service) AttachPurchaseHook(h PurchaseHook) { s.hook = h }
func (s *Service) AttachLegacyCounts(p CountProvider) { s.legacy = p }
// AttachPackageRules enforces the client's type-2/type-8 step and relay
// ordering using versioned contentsGroupId/contentsSortId, never SKU numbers.
func (s *Service) AttachPackageRules(packages []gamedata.CashPackageDesign) error {
rules := map[gamedata.CashProductKey][]gamedata.CashProductKey{}
for _, p := range packages {
if (p.PackageType != 2 && p.PackageType != 8) || p.ContentsGroupID == 0 || p.ContentsSortID <= 1 {
continue
}
key := gamedata.CashProductKey{GroupID: p.GroupID, ProductID: p.ID, SaleGroup: p.SaleGroup}
var prior []gamedata.CashProductKey
for _, q := range packages {
if q.PackageType == p.PackageType && q.ContentsGroupID == p.ContentsGroupID && q.ContentsSortID == p.ContentsSortID-1 {
prior = append(prior, gamedata.CashProductKey{GroupID: q.GroupID, ProductID: q.ID, SaleGroup: q.SaleGroup})
}
}
// Some current-version packages have a sort label above one but an
// independent contents group. Only a real same-group predecessor is a
// progression rule; do not invent links between adjacent product IDs.
if len(prior) == 0 {
continue
}
rules[key] = prior
}
s.predecessors = rules
return nil
}
func (s *Service) predecessorBought(v purchaseState, key gamedata.CashProductKey) bool {
prior := s.predecessors[key]
if len(prior) == 0 {
return true
}
for _, p := range prior {
if v.Counts[skuKey(p)].Lifetime > 0 {
return true
}
}
if s.legacy != nil {
for _, raw := range s.legacy.PurchaseCountDBInfos() {
g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2)
sale, _, _ := wire.Varint(raw, 3)
n, _, _ := wire.Varint(raw, 4)
if n > 0 {
for _, p := range prior {
if p.GroupID == g && p.ProductID == id && p.SaleGroup == sale {
return true
}
}
}
}
}
return false
}
// AttachShopSeed retains versioned dynamic windows and event identities. It
// must run before serving sessions; absent windows never authorize timed goods.
func (s *Service) AttachShopSeed(seed *readonly.Seed) error {
if seed == nil {
return fmt.Errorf("commerce: missing shop seed")
}
_, raw, handled, err := seed.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 1))
if err != nil || !handled {
return fmt.Errorf("commerce: invalid shop seed: %w", err)
}
windows := map[gamedata.CashProductKey][2]uint64{}
var products [][]byte
err = wire.Walk(raw, func(f wire.Field) error {
if f.Number != 1 {
return nil
}
if f.Type != 2 {
return fmt.Errorf("commerce: invalid shop product")
}
g, _, e := wire.Varint(f.Value, 1)
if e != nil {
return e
}
id, _, e := wire.Varint(f.Value, 2)
if e != nil {
return e
}
sale, _, e := wire.Varint(f.Value, 3)
if e != nil {
return e
}
start, _, e := wire.Varint(f.Value, 4)
if e != nil {
return e
}
end, _, e := wire.Varint(f.Value, 5)
if e != nil {
return e
}
k := gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}
if _, ok := windows[k]; ok {
return fmt.Errorf("commerce: duplicate shop schedule")
}
if end != 0 && start >= end {
return fmt.Errorf("commerce: invalid shop window")
}
windows[k] = [2]uint64{start, end}
products = append(products, append([]byte(nil), f.Value...))
return nil
})
if err != nil {
return err
}
s.shopProducts = products
s.shopWindows = windows
return nil
}
func (s *Service) available(d gamedata.CashProductDesign) bool {
w, ok := s.shopWindows[d.Key]
if !ok {
return d.TimeLimitType == 0
}
now := uint64(s.now().UnixMilli())
return w[0] <= now && (w[1] == 0 || now < w[1])
}
func (s *Service) IsAvailable(key gamedata.CashProductKey) bool {
d, ok := s.catalog.Design(key)
return ok && s.available(d)
}
func (s *Service) shopInfo() []byte {
var response []byte
for _, raw := range s.shopProducts {
g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2)
sale, _, _ := wire.Varint(raw, 3)
d, ok := s.catalog.Design(gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale})
if ok && s.available(d) {
response = wire.AppendBytes(response, 1, raw)
}
}
t := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second)
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
daily := day.AddDate(0, 0, 1)
days := (8 - int(day.Weekday())) % 7
if days == 0 {
days = 7
}
weekly := day.AddDate(0, 0, days)
monthly := time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, time.UTC)
for i, reset := range []time.Time{daily, weekly, monthly} {
response = wire.AppendVarint(response, i+2, uint64(reset.Add(time.Duration(s.resetSeconds)*time.Second).UnixMilli()))
}
return response
}
func (s *Service) SetClock(now func() time.Time, resetSeconds int64) {
s.now = now
s.resetSeconds = resetSeconds
}
func skuKey(k gamedata.CashProductKey) string {
return fmt.Sprintf("%d:%d:%d", k.GroupID, k.ProductID, k.SaleGroup)
}
func (s *Service) load() (purchaseState, error) {
v := purchaseState{Receipts: map[string]purchaseReceipt{}, Billing: map[string]string{}, Counts: map[string]purchaseCount{}}
b, e := s.store.Load("commerce")
if e != nil || b == nil {
return v, e
}
if e = stateio.RequireExactJSONObject(b, "receipts", "billing", "counts"); e != nil {
return v, e
}
if e = json.Unmarshal(b, &v); e != nil {
return v, e
}
if v.Receipts == nil || v.Billing == nil || v.Counts == nil {
return v, fmt.Errorf("commerce: invalid state")
}
for identity, receipt := range v.Receipts {
digest, err := hex.DecodeString(receipt.Digest)
if identity == "" || err != nil || len(digest) != sha256.Size || len(receipt.Response) == 0 {
return v, fmt.Errorf("commerce: invalid saved receipt")
}
}
for _, identity := range v.Billing {
if _, ok := v.Receipts[identity]; !ok {
return v, fmt.Errorf("commerce: billing identity has no purchase receipt")
}
}
for key, count := range v.Counts {
if key == "" || count.Period == "" || count.Count == 0 || count.Count > math.MaxInt32 || count.Lifetime < count.Count {
return v, fmt.Errorf("commerce: invalid saved purchase count")
}
}
return v, nil
}
func (s *Service) period(typ uint64) string {
t := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second)
switch typ {
case 1:
return t.Format("2006-01-02")
case 2:
y, w := t.ISOWeek()
return fmt.Sprintf("%d-W%02d", y, w)
case 3:
return t.Format("2006-01")
default:
return "account"
}
}
func (s *Service) count(v purchaseState, d gamedata.CashProductDesign) uint64 {
c := v.Counts[skuKey(d.Key)]
if c.Period != s.period(d.PurchaseLimitType) {
return 0
}
return c.Count
}
func (s *Service) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
switch path {
case "/CashShopInfo":
if s.shopWindows == nil {
return 0, nil, false, nil
}
if seq, ok, err := wire.Varint(request, 1); err != nil || !ok || seq == 0 || seq > math.MaxInt32 {
return 60, nil, true, fmt.Errorf("commerce: invalid shop sequence")
}
return 60, s.shopInfo(), true, nil
case "/CashShopPurchaseCountInfo":
if seq, ok, err := wire.Varint(request, 1); err != nil || !ok || seq == 0 || seq > math.MaxInt32 {
return 432, nil, true, fmt.Errorf("commerce: invalid count sequence")
}
infos, err := s.purchaseCountDBInfos()
if err != nil {
return 0, nil, true, err
}
var b []byte
for _, i := range infos {
b = wire.AppendBytes(b, 1, i)
}
return 432, b, true, nil
case "/CashShopBuy":
return s.buy(session, request)
default:
return 0, nil, false, nil
}
}
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
return s.HandleSession(path, request, "")
}
type buyLine struct {
Key gamedata.CashProductKey
Count uint64
}
func parseBuy(request []byte) (uint64, []buyLine, []string, error) {
seq, found, e := wire.Varint(request, 1)
if e != nil || !found || seq == 0 || seq > math.MaxInt32 {
return 0, nil, nil, fmt.Errorf("commerce: invalid sequence")
}
group, found, e := wire.Varint(request, 3)
if e != nil || !found || group == 0 {
return 0, nil, nil, fmt.Errorf("commerce: invalid product group")
}
var lines []buyLine
var billing []string
seen := map[gamedata.CashProductKey]bool{}
e = wire.Walk(request, func(f wire.Field) error {
switch f.Number {
case 4:
if f.Type != 2 {
return fmt.Errorf("commerce: invalid buy info")
}
id, ok, err := wire.Varint(f.Value, 1)
if err != nil || !ok || id == 0 {
return fmt.Errorf("commerce: invalid product id")
}
sale, _, err := wire.Varint(f.Value, 2)
if err != nil {
return err
}
n, ok, err := wire.Varint(f.Value, 3)
if err != nil || !ok || n == 0 || n > math.MaxInt32 {
return fmt.Errorf("commerce: invalid buy count")
}
k := gamedata.CashProductKey{GroupID: group, ProductID: id, SaleGroup: sale}
if seen[k] {
return fmt.Errorf("commerce: duplicate buy info")
}
seen[k] = true
lines = append(lines, buyLine{k, n})
case 7:
if f.Type != 2 {
return fmt.Errorf("commerce: invalid billing info")
}
return wire.Walk(f.Value, func(b wire.Field) error {
if b.Number == 1 || b.Number == 2 {
if b.Type != 2 {
return fmt.Errorf("commerce: invalid billing identity")
}
if len(b.Value) > 16384 {
return fmt.Errorf("commerce: excessive billing identity")
}
if len(b.Value) > 0 {
h := sha256.Sum256(b.Value)
billing = append(billing, fmt.Sprintf("%d:%x", b.Number, h))
}
}
return nil
})
}
return nil
})
if e == nil && len(lines) == 0 {
e = fmt.Errorf("commerce: empty purchase")
}
return seq, lines, billing, e
}
func (s *Service) buy(session string, request []byte) (int, []byte, bool, error) {
if session == "" {
return 61, nil, true, fmt.Errorf("commerce: authenticated session required")
}
seq, lines, billing, e := parseBuy(request)
if e != nil {
return 61, nil, true, e
}
var native, cash bool
for _, line := range lines {
if design, ok := s.catalog.Design(line.Key); ok {
if design.PriceType == 1 {
cash = true
} else {
native = true
}
}
}
if native && !cash {
return 0, nil, false, nil
}
if native {
return 61, nil, true, fmt.Errorf("commerce: mixed cash and native purchase")
}
h := sha256.Sum256(request)
digest := hex.EncodeToString(h[:])
sessionHash := sha256.Sum256([]byte(session))
identity := fmt.Sprintf("commerce:%x:%d", sessionHash, seq)
s.mu.Lock()
defer s.mu.Unlock()
v, e := s.load()
if e != nil {
return 61, nil, true, e
}
if r, ok := v.Receipts[identity]; ok {
if r.Digest != digest {
return 61, nil, true, fmt.Errorf("commerce: sequence reused with different purchase")
}
return 61, append([]byte(nil), r.Response...), true, nil
}
for _, b := range billing {
if _, ok := v.Billing[b]; ok {
return 61, nil, true, fmt.Errorf("commerce: billing receipt already used")
}
}
// Validate every line before charging the first one.
designs := make([]gamedata.CashProductDesign, len(lines))
quotes := make([]Product, len(lines))
for i, l := range lines {
if !s.predecessorBought(v, l.Key) {
return 61, nil, true, fmt.Errorf("commerce: preceding package purchase required")
}
d, ok := s.catalog.Design(l.Key)
if !ok {
return 61, nil, true, fmt.Errorf("commerce: unknown product")
}
if !s.available(d) {
return 61, nil, true, fmt.Errorf("commerce: product is not currently available")
}
q, err := s.catalog.Quote(l.Key, l.Count)
if err != nil {
return 61, nil, true, err
}
if !q.Enabled {
return 61, nil, true, fmt.Errorf("commerce: product disabled")
}
if l.Count > 1 && d.BulkOrderAvailability == 0 {
return 61, nil, true, fmt.Errorf("commerce: bulk purchase disabled")
}
n := s.count(v, d)
if s.legacy != nil {
for _, raw := range s.legacy.PurchaseCountDBInfos() {
g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2)
sale, _, _ := wire.Varint(raw, 3)
legacyCount, _, _ := wire.Varint(raw, 4)
if g == l.Key.GroupID && id == l.Key.ProductID && sale == l.Key.SaleGroup && legacyCount > n {
n = legacyCount
}
}
}
if d.PurchaseLimitType > 4 {
return 61, nil, true, fmt.Errorf("commerce: unknown purchase limit")
}
if n > math.MaxInt32-l.Count {
return 61, nil, true, fmt.Errorf("commerce: purchase count overflow")
}
if d.PurchaseLimitType != 0 && (d.PurchaseLimitCount == 0 || n+l.Count > d.PurchaseLimitCount) {
return 61, nil, true, fmt.Errorf("commerce: purchase limit exceeded")
}
designs[i] = d
quotes[i] = q
}
if e = validateAcceptedQuote(request, quotes); e != nil {
return 61, nil, true, e
}
var bundle []byte
for i, l := range lines {
d, q := designs[i], quotes[i]
operation := fmt.Sprintf("%s:%d", identity, i)
var costs []gamedata.Reward
if q.Cost > 0 {
var typ uint64
switch q.Currency {
case "paid_diamonds":
typ = 2
case "diamonds":
typ = 3
case "gold":
typ = 4
default:
return 61, nil, true, fmt.Errorf("commerce: unknown currency %q", q.Currency)
}
costs = []gamedata.Reward{{Type: typ, Count: q.Cost}}
}
var special []byte
handled := false
// Delegate must only select known special products. Parent account transaction
// guarantees its grant and the subsequent debit commit together.
if s.delegate != nil {
special, handled, e = s.delegate(l.Key, request)
if e != nil {
return 61, nil, true, e
}
if handled && (len(lines) != 1 || l.Count != 1) {
return 61, nil, true, fmt.Errorf("commerce: special purchase requires one product")
}
}
var rewards []gamedata.Reward
if !handled {
if d.RandomBoxID == 0 {
return 61, nil, true, fmt.Errorf("commerce: product reward missing")
}
rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.RandomBoxID, Count: l.Count})
if d.BonusRandomBoxID != 0 {
rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.BonusRandomBoxID, Count: l.Count})
}
}
b, err := s.economy.Apply(operation, costs, rewards)
if err != nil {
return 61, nil, true, err
}
bundle = append(bundle, b...)
bundle = append(bundle, special...)
if s.hook != nil {
if err = s.hook(operation, d, l.Count); err != nil {
return 61, nil, true, err
}
}
old := v.Counts[skuKey(l.Key)]
if old.Lifetime > math.MaxUint64-l.Count {
return 61, nil, true, fmt.Errorf("commerce: lifetime purchase count overflow")
}
v.Counts[skuKey(l.Key)] = purchaseCount{Count: s.count(v, d) + l.Count, Period: s.period(d.PurchaseLimitType), Lifetime: old.Lifetime + l.Count}
}
response := wire.AppendBytes(nil, 1, bundle)
v.Receipts[identity] = purchaseReceipt{digest, response}
for _, b := range billing {
v.Billing[b] = identity
}
encoded, e := json.Marshal(v)
if e == nil {
e = s.store.Save("commerce", encoded)
}
return 61, response, true, e
}
// The receipt repeats the client-visible quote for stale-catalog detection.
// Prices always come from Catalog, and a mismatch never changes the debit.
func validateAcceptedQuote(request []byte, quotes []Product) error {
var pay, receipt string
seen := false
err := wire.Walk(request, func(f wire.Field) error {
if f.Number != 7 {
return nil
}
if seen || f.Type != 2 {
return fmt.Errorf("commerce: invalid billing info")
}
seen = true
return wire.Walk(f.Value, func(b wire.Field) error {
if b.Number == 1 {
if b.Type != 2 || pay != "" {
return fmt.Errorf("commerce: invalid pay id")
}
pay = string(b.Value)
}
if b.Number == 2 {
if b.Type != 2 || receipt != "" {
return fmt.Errorf("commerce: invalid receipt")
}
receipt = string(b.Value)
}
return nil
})
})
if err != nil {
return err
}
parts := strings.Split(receipt, ":")
if len(parts) != 4 || parts[0] != "bd2-local-commerce-v1" || parts[1] != pay {
return fmt.Errorf("commerce: local billing quote required")
}
payID, err := strconv.ParseUint(pay, 10, 64)
if err != nil || payID == 0 || strconv.FormatUint(payID, 10) != pay {
return fmt.Errorf("commerce: invalid local pay id")
}
typ, err := strconv.ParseUint(parts[2], 10, 64)
if err != nil || strconv.FormatUint(typ, 10) != parts[2] {
return fmt.Errorf("commerce: invalid accepted quote currency")
}
amount, err := strconv.ParseUint(parts[3], 10, 64)
if err != nil || strconv.FormatUint(amount, 10) != parts[3] {
return fmt.Errorf("commerce: invalid accepted quote amount")
}
var expected uint64
for _, q := range quotes {
if q.ItemType != typ || q.Cost > math.MaxInt32-expected {
return fmt.Errorf("commerce: accepted quote currency mismatch")
}
expected += q.Cost
}
if expected != amount {
return fmt.Errorf("commerce: purchase quote changed; refresh the shop")
}
return nil
}
func (s *Service) purchaseCountDBInfos() ([][]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
v, e := s.load()
if e != nil {
return nil, e
}
counts := map[gamedata.CashProductKey]uint64{}
if s.legacy != nil {
for _, raw := range s.legacy.PurchaseCountDBInfos() {
g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2)
sale, _, _ := wire.Varint(raw, 3)
n, _, _ := wire.Varint(raw, 4)
counts[gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}] = n
}
}
for _, d := range s.catalog.Designs() {
if n := s.count(v, d); n > counts[d.Key] {
counts[d.Key] = n
}
}
keys := make([]gamedata.CashProductKey, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
a, b := keys[i], keys[j]
if a.GroupID != b.GroupID {
return a.GroupID < b.GroupID
}
if a.ProductID != b.ProductID {
return a.ProductID < b.ProductID
}
return a.SaleGroup < b.SaleGroup
})
var out [][]byte
for _, k := range keys {
b := wire.AppendVarint(nil, 1, k.GroupID)
b = wire.AppendVarint(b, 2, k.ProductID)
b = wire.AppendVarint(b, 3, k.SaleGroup)
b = wire.AppendVarint(b, 4, counts[k])
out = append(out, b)
}
return out, nil
}
func (s *Service) PurchaseCountDBInfos() [][]byte { v, _ := s.purchaseCountDBInfos(); return v }
func (s *Service) HasPurchased(k gamedata.CashProductKey) bool {
s.mu.Lock()
defer s.mu.Unlock()
v, e := s.load()
if e != nil {
return false
}
return v.Counts[skuKey(k)].Count > 0
}
// ConsumeEntitlement is an authorization callback within the caller's account
// transaction. Each successful cash activation consumes one lifetime purchase.
// It must not be called recursively from the purchase hook or delegate.
func (s *Service) ConsumeEntitlement(k gamedata.CashProductKey) bool {
s.mu.Lock()
defer s.mu.Unlock()
v, err := s.load()
if err != nil {
return false
}
n := v.Counts[skuKey(k)].Lifetime
used := map[string]uint64{}
raw, err := s.store.Load("commerce_pass_receipts")
if err != nil {
return false
}
if raw != nil {
if err = json.Unmarshal(raw, &used); err != nil || used == nil {
return false
}
}
key := skuKey(k)
if used[key] >= n {
return false
}
used[key]++
raw, err = json.Marshal(used)
if err != nil {
return false
}
return s.store.Save("commerce_pass_receipts", raw) == nil
}
+316
View File
@@ -0,0 +1,316 @@
package commerce
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gameconfig"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/readonly"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"testing"
"time"
)
type purchaseEconomy struct {
calls int
costs, rewards []gamedata.Reward
fail bool
}
func (e *purchaseEconomy) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) {
e.calls++
e.costs = c
e.rewards = r
if e.fail {
return nil, fmt.Errorf("insufficient balance")
}
return []byte{8, 1}, nil
}
func serviceFixture(t *testing.T, limit uint64) (*Service, *purchaseEconomy, *stateio.Memory) {
t.Helper()
design := &gamedata.CashCatalog{Products: []gamedata.CashProductDesign{
{Key: gamedata.CashProductKey{GroupID: 1, ProductID: 1}, GoogleSKU: "topup", PriceType: 1, PriceCount: 2200, RandomBoxID: 10, Recharge: true, NominalPaidDiamonds: 100},
{Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1}, GoogleSKU: "goods", PriceType: 1, PriceCount: 22000, RandomBoxID: 20, PurchaseLimitType: limit, PurchaseLimitCount: 1},
{Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, GoogleSKU: "goods-sale", PriceType: 1, PriceCount: 11000, RandomBoxID: 21, PurchaseLimitType: 4, PurchaseLimitCount: 1},
}}
c, err := NewCatalog("test", design, gameconfig.Default().Purchases)
if err != nil {
t.Fatal(err)
}
eco := &purchaseEconomy{}
store := stateio.NewMemory()
s, err := NewService(c, store, eco)
if err != nil {
t.Fatal(err)
}
return s, eco, store
}
func buyRequest(seq, group, id, sale uint64, billing string) []byte {
b := wire.AppendVarint(nil, 1, seq)
b = wire.AppendVarint(b, 3, group)
line := wire.AppendVarint(nil, 1, id)
line = wire.AppendVarint(line, 2, sale)
line = wire.AppendVarint(line, 3, 1)
b = wire.AppendBytes(b, 4, line)
if billing == "" {
billing = fmt.Sprint(seq)
}
if billing == "pay-1" {
billing = "1"
}
typ, amount := uint64(2), uint64(1000)
if sale == 1 {
amount = 500
}
if group == 1 {
typ = 0
amount = 0
}
info := wire.AppendBytes(nil, 1, []byte(billing))
info = wire.AppendBytes(info, 2, []byte(fmt.Sprintf("bd2-local-commerce-v1:%s:%d:%d", billing, typ, amount)))
b = wire.AppendBytes(b, 7, info)
return b
}
func TestPurchaseReplayPersistsAndRejectsCollisions(t *testing.T) {
s, eco, store := serviceFixture(t, 0)
req := buyRequest(1, 2, 1, 0, "pay-1")
code, out, ok, err := s.HandleSession("/CashShopBuy", req, "session")
if err != nil || !ok || code != 61 {
t.Fatal(code, ok, err)
}
if eco.calls != 1 || len(eco.costs) != 1 || eco.costs[0].Type != 2 || eco.costs[0].Count != 1000 || eco.rewards[0].ID != 20 {
t.Fatal(eco)
}
s2, err := NewService(s.catalog, store, eco)
if err != nil {
t.Fatal(err)
}
_, replayed, _, err := s2.HandleSession("/CashShopBuy", req, "session")
if err != nil || !bytes.Equal(out, replayed) || eco.calls != 1 {
t.Fatal(err, eco.calls)
}
changed := buyRequest(1, 2, 1, 1, "")
if _, _, _, err = s2.HandleSession("/CashShopBuy", changed, "session"); err == nil {
t.Fatal("changed sequence accepted")
}
if _, _, _, err = s2.HandleSession("/CashShopBuy", req, "other-session"); err == nil {
t.Fatal("billing identity reused")
}
}
func TestSaleGroupsLimitsAndReset(t *testing.T) {
s, eco, _ := serviceFixture(t, 1)
now := time.Date(2026, 10, 5, 1, 0, 0, 0, time.UTC)
s.SetClock(func() time.Time { return now }, 0)
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err == nil {
t.Fatal("daily limit ignored")
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(3, 2, 1, 1, ""), "s"); err != nil {
t.Fatal("sale group should be independent", err)
}
now = now.Add(24 * time.Hour)
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(4, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if eco.calls != 3 {
t.Fatal(eco.calls)
}
}
func TestFailedEconomyDoesNotConsumeLimitAndFreeRecharge(t *testing.T) {
s, eco, _ := serviceFixture(t, 4)
eco.fail = true
req := buyRequest(1, 2, 1, 0, "")
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil {
t.Fatal("failure accepted")
}
if s.HasPurchased(gamedata.CashProductKey{GroupID: 2, ProductID: 1}) {
t.Fatal("failed buy persisted")
}
eco.fail = false
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 1, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if len(eco.costs) != 0 || eco.rewards[0].ID != 10 {
t.Fatal("free recharge wrong", eco)
}
}
func TestAuthenticatedSessionRequired(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
if _, _, _, err := s.Handle("/CashShopBuy", buyRequest(1, 2, 1, 0, "")); err == nil || eco.calls != 0 {
t.Fatal("unauthenticated buy accepted")
}
}
func TestPassEntitlementConsumedOncePerPurchase(t *testing.T) {
s, _, _ := serviceFixture(t, 0)
k := gamedata.CashProductKey{GroupID: 2, ProductID: 1}
if s.ConsumeEntitlement(k) {
t.Fatal("unpaid entitlement authorized")
}
for i := uint64(1); i <= 2; i++ {
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(i, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if !s.ConsumeEntitlement(k) || s.ConsumeEntitlement(k) {
t.Fatal("entitlement count not enforced")
}
}
}
func TestRelayRequiresPreviousProduct(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
if err := s.AttachPackageRules([]gamedata.CashPackageDesign{{GroupID: 2, ID: 1, PackageType: 8, ContentsGroupID: 7, ContentsSortID: 1}, {GroupID: 2, ID: 1, SaleGroup: 1, PackageType: 8, ContentsGroupID: 7, ContentsSortID: 2}}); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 1, ""), "s"); err == nil || eco.calls != 0 {
t.Fatal("relay step skipped")
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(3, 2, 1, 1, ""), "s"); err != nil {
t.Fatal(err)
}
}
func TestStaleClientQuoteRejectedBeforeEconomy(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
req := buyRequest(1, 2, 1, 0, "")
req = bytes.ReplaceAll(req, []byte(":2:1000"), []byte(":3:1000"))
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 0 {
t.Fatal("stale currency accepted")
}
req = buyRequest(2, 2, 1, 0, "")
req = bytes.ReplaceAll(req, []byte(":2:1000"), []byte(":2:9999"))
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 0 {
t.Fatal("stale amount accepted")
}
}
func TestShopWindowFiltersAndAuthorizesProducts(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
s.SetClock(func() time.Time { return now }, 0)
seed := &readonly.Seed{Responses: map[string]readonly.Response{"/CashShopInfo": {PacketCode: 60, Fields: []readonly.Field{{Number: 1, Type: 2, Fields: []readonly.Field{{Number: 1, Type: 0, Varint: 2}, {Number: 2, Type: 0, Varint: 1}, {Number: 4, Type: 0, Varint: uint64(now.Add(-time.Hour).UnixMilli())}, {Number: 5, Type: 0, Varint: uint64(now.Add(time.Hour).UnixMilli())}, {Number: 8, Type: 0, Varint: 123}}}}}}}
if err := s.AttachShopSeed(seed); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
now = now.Add(2 * time.Hour)
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err == nil {
t.Fatal("expired product accepted")
}
code, out, ok, err := s.HandleSession("/CashShopInfo", wire.AppendVarint(nil, 1, 1), "s")
if err != nil || !ok || code != 60 {
t.Fatal(err)
}
products := 0
_ = wire.Walk(out, func(f wire.Field) error {
if f.Number == 1 {
products++
}
return nil
})
if products != 0 || eco.calls != 1 {
t.Fatal(products, eco.calls)
}
}
type walletPurchaseEconomy struct {
wallet *player.Wallet
store stateio.Store
}
func (e walletPurchaseEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
if err := e.wallet.ExchangeOnce(identity, costs, nil); err != nil {
return nil, err
}
raw, err := json.Marshal(rewards)
if err != nil {
return nil, err
}
if err = e.store.Save("commerce_test_rewards", raw); err != nil {
return nil, err
}
return []byte{8, 1}, nil
}
func TestSQLiteAccountTransactionRollsBackCostRewardAndReceipt(t *testing.T) {
s, _, _ := serviceFixture(t, 4)
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = repo.Close() })
wallet, err := player.OpenWallet(repo, player.Currency{Jewelry: 1000})
if err != nil {
t.Fatal(err)
}
if err = wallet.EnsurePersisted(); err != nil {
t.Fatal(err)
}
s, err = NewService(s.catalog, repo, walletPurchaseEconomy{wallet, repo})
if err != nil {
t.Fatal(err)
}
s.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { return fmt.Errorf("entitlement failure") })
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err = s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err == nil {
t.Fatal("hook failure lost")
}
if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatal(err)
}
if err = repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer repo.Close()
wallet, err = player.OpenWallet(repo, player.Currency{})
if err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Jewelry != 1000 {
t.Fatal("paid diamonds survived rollback")
}
for _, name := range []string{"commerce", "commerce_test_rewards"} {
raw, err := repo.Load(name)
if err != nil || raw != nil {
t.Fatal("purchase state survived rollback", name, err)
}
}
s, err = NewService(s.catalog, repo, walletPurchaseEconomy{wallet, repo})
if err != nil {
t.Fatal(err)
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err = s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
_ = op.Rollback()
t.Fatal(err)
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Jewelry != 0 || !s.HasPurchased(gamedata.CashProductKey{GroupID: 2, ProductID: 1}) {
t.Fatal("retry did not commit")
}
}
+164
View File
@@ -0,0 +1,164 @@
package eventactions
import (
"bd2server/internal/server/events"
"bd2server/internal/server/wire"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
)
// Tactics uses the client's custom battle stage fields 8/9 and has fixed
// tutorial decks. Character health and ordinary pack rewards do not apply.
func (s *Service) HandlesBattle(mode uint64) bool { return mode == 29 }
func (s *Service) EnterBattle(req []byte, receipt string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
digest := battleDigest(req)
if r, ok := s.state.Receipts[s.session+":enter:"+receipt]; ok {
if r.Digest != digest {
return nil, errors.New("eventactions: changed battle enter replay")
}
return r.Reply, nil
}
before, _ := json.Marshal(s.state)
if val(req, 5) != 29 {
return nil, errors.New("eventactions: wrong tactics battle mode")
}
group, stage, deck := val(req, 8), val(req, 9), val(req, 4)
row, ok := s.row("TacticsBingoTable", 4, 5, group, stage)
if !ok || row.V(2) != deck {
return nil, errors.New("eventactions: invalid tactics stage/deck")
}
var uid uint64
for _, v := range s.registry.List() {
if v.Type != 20 || !s.active(v) {
continue
}
g, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID)
if ok && g.V(1) == group {
uid = v.UID
break
}
}
if uid == 0 {
return nil, errors.New("eventactions: tactics event inactive")
}
s.state.BattleUID, s.state.BattleStage, s.state.BattleDeck = uid, stage, deck
s.state.Receipts[s.session+":enter:"+receipt] = receiptRecord(nil, digest)
if e := s.save(); e != nil {
_ = json.Unmarshal(before, &s.state)
return nil, e
}
return nil, nil
}
func (s *Service) CompleteBattle(req []byte, receipt string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
if r, ok := s.state.Receipts[s.session+":battle:"+receipt]; ok {
if r.Digest != battleDigest(req) {
return nil, errors.New("eventactions: changed battle completion replay")
}
return r.Reply, nil
}
before, _ := json.Marshal(s.state)
uid, stage := s.state.BattleUID, s.state.BattleStage
if uid == 0 || stage == 0 {
return nil, errors.New("eventactions: tactics battle not entered")
}
if _, e := s.resolve(uid, 20); e != nil {
return nil, e
}
if val(req, 2) == 1 && !contains(s.state.Tactics[uid], stage) {
priorLines := s.tacticsLines(uid)
s.state.Tactics[uid] = append(s.state.Tactics[uid], stage)
if s.progress != nil {
if e := s.progress(346, stage, 1); e != nil {
_ = json.Unmarshal(before, &s.state)
return nil, e
}
lines := s.tacticsLines(uid)
if lines > priorLines {
if e := s.progress(347, 0, lines-priorLines); e != nil {
_ = json.Unmarshal(before, &s.state)
return nil, e
}
}
v, _ := s.registry.Resolve(uid)
g, _ := s.design.Row("TacticsBingoGroupTable", 3, v.ID)
n := uint64(5) - g.V(4)
if uint64(len(s.state.Tactics[uid])) == n*n {
if e := s.progress(348, 0, 1); e != nil {
_ = json.Unmarshal(before, &s.state)
return nil, e
}
}
}
}
var out []byte
for _, id := range s.state.Tactics[uid] {
out = wire.AppendVarint(out, 29, id)
}
s.state.BattleUID, s.state.BattleStage, s.state.BattleDeck = 0, 0, 0
s.state.Receipts[s.session+":battle:"+receipt] = receiptRecord(out, battleDigest(req))
if e := s.save(); e != nil {
_ = json.Unmarshal(before, &s.state)
return nil, e
}
return out, nil
}
func (s *Service) tacticsLines(uid uint64) uint64 {
v, _ := s.registry.Resolve(uid)
g, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID)
if !ok || g.V(4) > 2 {
return 0
}
n := uint64(5) - g.V(4)
clear := s.state.Tactics[uid]
var lines uint64
for i := uint64(0); i < n; i++ {
row, col := true, true
for j := uint64(0); j < n; j++ {
row = row && contains(clear, i*n+j+1)
col = col && contains(clear, j*n+i+1)
}
if row {
lines++
}
if col {
lines++
}
}
a, b := true, true
for i := uint64(0); i < n; i++ {
a = a && contains(clear, i*n+i+1)
b = b && contains(clear, i*n+(n-i))
}
if a {
lines++
}
if b {
lines++
}
return lines
}
func (s *Service) AssociatedMissionGroup(v events.Schedule) uint64 {
if v.Type == 20 {
r, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID)
if ok {
return r.V(2)
}
}
if v.Type == 23 {
r, ok := s.design.Row("VotingEventTable", 6, v.ID)
if ok {
return r.V(12)
}
}
return 0
}
func receiptRecord(b []byte, digest string) receipt { return receipt{Digest: digest, Reply: b} }
func battleDigest(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) }
@@ -0,0 +1,56 @@
package eventactions
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"errors"
)
func (s *Service) cafeteriaReward(req []byte, identity string) ([]byte, error) {
group, id := val(req, 2), val(req, 3)
row, ok := s.row("CafeteriaEventTable", 5, 6, group, id)
if !ok || row.V(13) == 0 || row.V(11) == 0 {
return nil, errors.New("eventactions: cafeteria interaction absent")
}
var uid uint64
for _, v := range s.registry.List() {
if v.Type == 25 && s.active(v) && (v.SubID == group || v.SubID == 0 && v.ID == group) {
uid = v.UID
break
}
}
if uid == 0 {
return nil, errors.New("eventactions: cafeteria event inactive")
}
if len(s.design.Tables["CafeteriaDefaultTable"]) != 1 {
return nil, errors.New("eventactions: cafeteria default missing")
}
defaults := s.design.Tables["CafeteriaDefaultTable"][0]
cap := defaults.V(8)
if cap == 0 || s.state.CafeteriaCurrency >= cap {
return nil, errors.New("eventactions: cafeteria daily currency limit")
}
term := defaults.V(3)
if row.V(4) != 1 {
term = defaults.V(28)
}
receiptKey := key(uid, group, id)
now := s.now().UnixMilli()
if last := s.state.CafeteriaLast[receiptKey]; last > 0 && now-last < int64(term)*1000 {
return nil, errors.New("eventactions: cafeteria interaction cooldown")
}
count := row.V(11)
if count > cap-s.state.CafeteriaCurrency {
count = cap - s.state.CafeteriaCurrency
}
rewards := []gamedata.Reward{{Type: row.V(13), ID: row.V(12), Count: count}}
bundle, err := s.economy.Apply(identity, nil, rewards)
if err != nil {
return nil, err
}
s.state.CafeteriaCurrency += count
s.state.CafeteriaLast[receiptKey] = now
out := wire.AppendBytes(nil, 1, bundle)
out = wire.AppendVarint(out, 2, s.state.CafeteriaCurrency)
return out, nil
}
@@ -0,0 +1,62 @@
package eventactions
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"bytes"
"testing"
"time"
)
func TestCafeteriaActiveScheduleDailyCapCooldownAndReplay(t *testing.T) {
s, e, store := setup(t)
now := s.now()
s.now = func() time.Time { return now }
s.design.Tables["CafeteriaDefaultTable"] = []gamedata.EventActionRow{row(map[int]uint64{8: 5, 3: 2, 28: 2})}
s.design.Tables["CafeteriaEventTable"] = []gamedata.EventActionRow{row(map[int]uint64{5: 1, 6: 1, 4: 1, 11: 3, 13: 44})}
b := wire.AppendVarint(req(1), 2, 1)
b = wire.AppendVarint(b, 3, 1)
if _, _, _, err := s.Handle("/CafeteriaEventNpcInteractionReward", b); err == nil {
t.Fatal("inactive cafeteria accepted")
}
r := events.NewRegistry()
if err := r.Replace([]events.Schedule{{UID: 77, Type: 25, ID: 3007, SubID: 1, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(48 * time.Hour).UnixMilli()}}); err != nil {
t.Fatal(err)
}
s.registry = r
code, reply, _, err := s.Handle("/CafeteriaEventNpcInteractionReward", b)
if err != nil || code != 414 || e.calls != 1 {
t.Fatalf("cafeteria%d err%v", code, err)
}
n, err := Open(store, s.design, r, e)
if err != nil {
t.Fatal(err)
}
n.now = s.now
n.BeginSession("test")
_, again, _, err := n.Handle("/CafeteriaEventNpcInteractionReward", b)
if err != nil || !bytes.Equal(reply, again) || e.calls != 1 {
t.Fatal("replay grants twice")
}
b2 := wire.AppendVarint(req(2), 2, 1)
b2 = wire.AppendVarint(b2, 3, 1)
if _, _, _, err = n.Handle("/CafeteriaEventNpcInteractionReward", b2); err == nil {
t.Fatal("cooldown bypass")
}
now = now.Add(2 * time.Second)
_, reply, _, err = n.Handle("/CafeteriaEventNpcInteractionReward", b2)
if err != nil {
t.Fatal(err)
}
count, _, _ := wire.Varint(reply, 2)
if count != 5 || e.rewards[len(e.rewards)-1].Count != 2 {
t.Fatal("daily cap not clamped")
}
b3 := wire.AppendVarint(req(3), 2, 1)
b3 = wire.AppendVarint(b3, 3, 1)
now = now.Add(24 * time.Hour)
if _, _, _, err = n.Handle("/CafeteriaEventNpcInteractionReward", b3); err != nil || n.state.CafeteriaCurrency != 3 {
t.Fatal("daily limit failed to reset")
}
}
+551
View File
@@ -0,0 +1,551 @@
// Package eventactions owns repeatable event interaction state. It uses the
// injected calendar and shared transactional reward service.
package eventactions
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
)
type Economy interface {
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
}
type receipt struct {
Digest string
Reply []byte
}
type spawn struct {
UID, Group, ID uint64
Start int64
Caught map[string]bool
}
type vote struct{ Round, Candidate, Normal, Additional uint64 }
type snapshot struct {
Version string
BattleUID, BattleStage, BattleDeck uint64
Claims map[string]bool
Favorites map[uint64]bool
Votes map[string]*vote
NormalVoted map[string]bool
VoteRewards map[string]bool
Spawns map[uint64]*spawn
DailyNormal, DailySpecial uint64
CafeteriaCurrency uint64
CafeteriaLast map[string]int64
Day string
Tactics map[uint64][]uint64
Deck []byte
Receipts map[string]receipt
}
type Service struct {
mu sync.Mutex
store stateio.Store
design *gamedata.EventActionsDesign
registry events.Resolver
economy Economy
state snapshot
now func() time.Time
session string
friendship func(uint64) uint64
chargeInfo func() ([]byte, error)
owned func(uint64, uint64) bool
progress func(uint64, uint64, uint64) error
voteTotals func(uint64, uint64) (map[uint64]uint64, error)
}
func Open(store stateio.Store, d *gamedata.EventActionsDesign, r events.Resolver, e Economy) (*Service, error) {
if store == nil || d == nil || r == nil || e == nil {
return nil, errors.New("eventactions: missing dependency")
}
s := &Service{store: store, design: d, registry: r, economy: e, now: time.Now}
b, err := store.Load("eventactions")
if err != nil {
return nil, err
}
if b != nil {
if err = stateio.RequireExactJSONObject(b, "Version", "BattleUID", "BattleStage", "BattleDeck", "Claims", "Favorites", "Votes", "NormalVoted", "VoteRewards", "Spawns", "DailyNormal", "DailySpecial", "CafeteriaCurrency", "CafeteriaLast", "Day", "Tactics", "Deck", "Receipts"); err != nil {
return nil, err
}
if err = json.Unmarshal(b, &s.state); err != nil {
return nil, err
}
if s.state.Version != versionconfig.State() || s.state.Claims == nil || s.state.Favorites == nil || s.state.Votes == nil || s.state.NormalVoted == nil || s.state.VoteRewards == nil || s.state.Spawns == nil || s.state.Tactics == nil || s.state.Receipts == nil || s.state.CafeteriaLast == nil {
return nil, errors.New("eventactions: invalid state layout")
}
for _, v := range s.state.Votes {
if v == nil || v.Candidate == 0 || v.Round == 0 {
return nil, errors.New("eventactions: invalid saved vote")
}
}
for _, p := range s.state.Spawns {
if p == nil || p.Caught == nil || p.ID == 0 {
return nil, errors.New("eventactions: invalid saved spawn")
}
}
} else {
s.state.Version = versionconfig.State()
}
s.init()
return s, nil
}
func (s *Service) init() {
if s.state.CafeteriaLast == nil {
s.state.CafeteriaLast = map[string]int64{}
}
if s.state.Claims == nil {
s.state.Claims = map[string]bool{}
}
if s.state.Favorites == nil {
s.state.Favorites = map[uint64]bool{}
}
if s.state.Votes == nil {
s.state.Votes = map[string]*vote{}
}
if s.state.NormalVoted == nil {
s.state.NormalVoted = map[string]bool{}
}
if s.state.VoteRewards == nil {
s.state.VoteRewards = map[string]bool{}
}
if s.state.Spawns == nil {
s.state.Spawns = map[uint64]*spawn{}
}
if s.state.Tactics == nil {
s.state.Tactics = map[uint64][]uint64{}
}
if s.state.Receipts == nil {
s.state.Receipts = map[string]receipt{}
}
}
func (s *Service) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id }
func (s *Service) AttachFriendshipLevel(f func(uint64) uint64) { s.friendship = f }
func (s *Service) AttachChargeInfo(f func() ([]byte, error)) { s.chargeInfo = f }
func (s *Service) AttachOwnedCharacter(f func(uint64, uint64) bool) { s.owned = f }
func (s *Service) AttachProgress(f func(uint64, uint64, uint64) error) { s.progress = f }
// The current server owns one account save. Its real votes are the default
// leaderboard; a future account aggregator can supply broader server totals.
func (s *Service) AttachVoteTotals(f func(uint64, uint64) (map[uint64]uint64, error)) {
s.voteTotals = f
}
func (s *Service) save() error {
b, e := json.Marshal(s.state)
if e != nil {
return e
}
return s.store.Save("eventactions", b)
}
func val(p []byte, n int) uint64 { v, _, _ := wire.Varint(p, n); return v }
func key(v ...uint64) string {
r := []string{}
for _, n := range v {
r = append(r, fmt.Sprint(n))
}
return strings.Join(r, "/")
}
func (s *Service) active(v events.Schedule) bool {
return s.now().UnixMilli() >= v.Start && s.now().UnixMilli() < v.End
}
func (s *Service) resolve(uid, t uint64) (events.Schedule, error) {
v, e := s.registry.Resolve(uid)
if e != nil {
return v, e
}
if v.Type != t || !s.active(v) {
return v, errors.New("eventactions: event inactive or wrong type")
}
return v, nil
}
func (s *Service) find(t uint64) (events.Schedule, bool) {
for _, v := range s.registry.List() {
if v.Type == t && s.active(v) {
return v, true
}
}
return events.Schedule{}, false
}
func (s *Service) roll() {
day := s.now().UTC().Format("2006-01-02")
if s.state.Day != day {
s.state.Day = day
s.state.DailyNormal = 0
s.state.DailySpecial = 0
s.state.CafeteriaCurrency = 0
s.state.CafeteriaLast = map[string]int64{}
s.state.NormalVoted = map[string]bool{}
}
}
func (s *Service) Handle(path string, b []byte) (int, []byte, bool, error) {
codes := map[string]int{"/FieldEventSpawnInfo": 551, "/FieldEventSpawnStart": 552, "/FieldEventSpawnReward": 553, "/FireWorksInfo": 554, "/FireWorksReward": 555, "/CharVoteInfo": 581, "/CharVoteSave": 582, "/CharVoteRanking": 583, "/CharVoteSeasonRanking": 584, "/CharVoteFavoriteAdd": 585, "/CharVoteFavoriteDelete": 586, "/CharVoteTotalRanking": 587, "/FriendshipSpecialEpisodeInfo": 623, "/FriendshipSpecialEpisodeClear": 624, "/ChargeCostInfo": 123, "/NpcQuizInfo": 535, "/NpcQuizClear": 536, "/TacticsBingoInfo": 546, "/TacticsBingoDeckSave": 550}
code, ok := codes[path]
if path == "/CafeteriaEventNpcInteractionReward" {
code, ok = 414, true
}
if !ok {
return 0, nil, false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
seq, found, e := wire.Varint(b, 1)
if e != nil || !found || seq == 0 {
return code, nil, true, errors.New("eventactions: invalid request")
}
if e = wire.Walk(b, func(wire.Field) error { return nil }); e != nil {
return code, nil, true, e
}
sum := sha256.Sum256(append([]byte(path), b...))
digest := hex.EncodeToString(sum[:])
rk := s.session + ":" + path + ":" + key(seq)
if r, ok := s.state.Receipts[rk]; ok {
if r.Digest != digest {
return code, nil, true, errors.New("eventactions: replay changed")
}
return code, r.Reply, true, nil
}
before, _ := json.Marshal(s.state)
s.roll()
out, e := s.handle(path, b, rk)
if e != nil {
_ = json.Unmarshal(before, &s.state)
return code, nil, true, e
}
s.state.Receipts[rk] = receipt{digest, out}
if e = s.save(); e != nil {
_ = json.Unmarshal(before, &s.state)
return code, nil, true, e
}
return code, out, true, nil
}
func (s *Service) row(table string, groupField, idField int, g, id uint64) (gamedata.EventActionRow, bool) {
for _, r := range s.design.Tables[table] {
if r.V(groupField) == g && r.V(idField) == id {
return r, true
}
}
return gamedata.EventActionRow{}, false
}
func (s *Service) spawnWire(p *spawn) []byte {
if p == nil {
return nil
}
b := wire.AppendVarint(nil, 1, uint64(p.Start))
b = wire.AppendVarint(b, 2, p.UID)
b = wire.AppendVarint(b, 3, p.ID)
b = wire.AppendVarint(b, 4, p.Group)
var ids []string
for k := range p.Caught {
ids = append(ids, k)
}
sort.Strings(ids)
for _, k := range ids {
var g, id uint64
_, _ = fmt.Sscanf(k, "%d/%d", &g, &id)
x := wire.AppendVarint(nil, 1, p.ID)
x = wire.AppendVarint(x, 2, g)
x = wire.AppendVarint(x, 3, id)
b = wire.AppendBytes(b, 5, x)
}
return b
}
func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) {
switch path {
case "/CafeteriaEventNpcInteractionReward":
return s.cafeteriaReward(b, identity)
case "/ChargeCostInfo":
if s.chargeInfo == nil {
return nil, errors.New("eventactions: charge state source unavailable")
}
return s.chargeInfo()
case "/FieldEventSpawnInfo":
uid := val(b, 2)
if _, e := s.resolve(uid, 21); e != nil {
return nil, e
}
out := wire.AppendBytes(nil, 1, s.spawnWire(s.state.Spawns[uid]))
out = wire.AppendVarint(out, 2, s.state.DailyNormal)
out = wire.AppendVarint(out, 3, s.state.DailySpecial)
return out, nil
case "/FieldEventSpawnStart":
v, e := s.resolve(val(b, 2), 21)
if e != nil {
return nil, e
}
g, id := val(b, 3), val(b, 4)
r, ok := s.row("FieldSpawnEventTable", 4, 5, g, id)
if !ok || g != v.ID {
return nil, errors.New("eventactions: invalid spawn event")
}
if p := s.state.Spawns[v.UID]; p != nil {
defaults, _ := s.design.Row("FieldEventDefaultTable", 9, 0)
expired := defaults.V(12) > 0 && s.now().UnixMilli()-p.Start >= int64(defaults.V(12))*1000
if expired {
delete(s.state.Spawns, v.UID)
} else {
if p.Group != g || p.ID != id {
return nil, errors.New("eventactions: spawn already started")
}
return wire.AppendBytes(nil, 1, s.spawnWire(p)), nil
}
}
_ = r
s.state.Spawns[v.UID] = &spawn{v.UID, g, id, s.now().UnixMilli(), map[string]bool{}}
return wire.AppendBytes(nil, 1, s.spawnWire(s.state.Spawns[v.UID])), nil
case "/FieldEventSpawnReward":
v, e := s.resolve(val(b, 6), 21)
if e != nil {
return nil, e
}
p := s.state.Spawns[v.UID]
if p == nil || p.ID != val(b, 2) || p.Group != val(b, 4) {
return nil, errors.New("eventactions: spawn not started")
}
spawnRow, ok := s.row("FieldSpawnEventTable", 4, 5, p.Group, p.ID)
if !ok {
return nil, errors.New("eventactions: spawn definition absent")
}
g, id := val(b, 5), val(b, 3)
r, ok := s.row("FieldEventMonsterTable", 3, 4, g, id)
if !ok || g != spawnRow.V(2) {
return nil, errors.New("eventactions: invalid spawn monster")
}
ck := key(g, id)
if p.Caught[ck] {
return nil, errors.New("eventactions: monster already caught")
}
defaults, _ := s.design.Row("FieldEventDefaultTable", 9, 0)
if defaults.V(12) > 0 && s.now().UnixMilli()-p.Start > int64(defaults.V(12))*1000 {
return nil, errors.New("eventactions: spawn time expired")
}
special := r.V(2) != 0
if special && s.state.DailySpecial >= defaults.V(5) || !special && s.state.DailyNormal >= defaults.V(4) {
return nil, errors.New("eventactions: daily catch limit")
}
reward, ok := s.design.SpawnRewards[[2]uint64{r.V(5), r.V(1)}]
if !ok || reward.Count == 0 {
return nil, errors.New("eventactions: spawn reward missing")
}
bundle, e := s.economy.Apply(identity, nil, []gamedata.Reward{reward})
if e != nil {
return nil, e
}
p.Caught[ck] = true
if special {
s.state.DailySpecial++
} else {
s.state.DailyNormal++
}
if s.progress != nil {
if e = s.progress(349, 0, 1); e != nil {
return nil, e
}
}
return wire.AppendBytes(nil, 1, bundle), nil
case "/FireWorksInfo":
var out []byte
for k := range s.state.Claims {
var uid, g uint64
if _, e := fmt.Sscanf(k, "fire:%d/%d", &uid, &g); e == nil {
out = wire.AppendVarint(out, 1, g)
}
}
return out, nil
case "/FireWorksReward":
v, e := s.resolve(val(b, 2), 22)
if e != nil {
return nil, e
}
g := val(b, 3)
if g != v.ID {
return nil, errors.New("eventactions: wrong fireworks group")
}
ck := "fire:" + key(v.UID, g)
if s.state.Claims[ck] {
return nil, errors.New("eventactions: fireworks already received")
}
var rs []gamedata.Reward
for _, r := range s.design.Tables["FireworksTable"] {
if r.V(4) == g {
rs = append(rs, r.Rewards...)
break
}
}
if len(rs) == 0 {
return nil, errors.New("eventactions: fireworks design missing")
}
bundle, e := s.economy.Apply(identity, nil, rs)
if e != nil {
return nil, e
}
s.state.Claims[ck] = true
return wire.AppendBytes(nil, 1, bundle), nil
case "/FriendshipSpecialEpisodeInfo":
var out []byte
for k := range s.state.Claims {
var g, id uint64
if _, e := fmt.Sscanf(k, "friend:%d/%d", &g, &id); e == nil {
x := wire.AppendVarint(nil, 1, g)
x = wire.AppendVarint(x, 2, id)
out = wire.AppendBytes(out, 1, x)
}
}
return out, nil
case "/FriendshipSpecialEpisodeClear":
g, id := val(b, 2), val(b, 3)
r, ok := s.row("FriendshipSpecialEpisodeTable", 5, 6, g, id)
if !ok {
return nil, errors.New("eventactions: unknown episode")
}
if s.friendship == nil || s.friendship(g) < r.V(12) {
return nil, errors.New("eventactions: friendship level insufficient")
}
if id > 1 && !s.state.Claims["friend:"+key(g, id-1)] {
return nil, errors.New("eventactions: previous episode incomplete")
}
ck := "friend:" + key(g, id)
if s.state.Claims[ck] {
return nil, errors.New("eventactions: episode already cleared")
}
rs := append([]gamedata.Reward(nil), r.Rewards...)
for _, v := range s.registry.List() {
if v.Type == 24 && v.ID == g && s.active(v) {
rs = append(rs, r.EventRewards...)
break
}
}
bundle, e := s.economy.Apply(identity, nil, rs)
if e != nil {
return nil, e
}
s.state.Claims[ck] = true
x := wire.AppendVarint(nil, 1, g)
x = wire.AppendVarint(x, 2, id)
out := wire.AppendBytes(nil, 1, bundle)
out = wire.AppendBytes(out, 2, x)
return out, nil
case "/NpcQuizInfo":
uid := val(b, 2)
v, e := s.registry.Resolve(uid)
if e != nil {
return nil, e
}
var out []byte
for _, r := range s.design.Tables["NpcQuizTable"] {
if r.V(1) != v.ID {
continue
}
if s.state.Claims["quiz:"+key(uid, r.V(1), r.V(2))] {
x := wire.AppendVarint(nil, 1, uid)
x = wire.AppendVarint(x, 2, r.V(1))
x = wire.AppendVarint(x, 3, r.V(2))
out = wire.AppendBytes(out, 1, x)
}
}
return out, nil
case "/NpcQuizClear":
uid, g, id := val(b, 2), val(b, 3), val(b, 4)
v, e := s.registry.Resolve(uid)
if e != nil {
return nil, e
}
if !s.active(v) {
return nil, errors.New("eventactions: quiz event inactive")
}
r, ok := s.row("NpcQuizTable", 1, 2, g, id)
if !ok || g != v.ID || s.now().UnixMilli() < v.Start+int64(r.V(8))*86400000 {
return nil, errors.New("eventactions: quiz locked")
}
ck := "quiz:" + key(uid, g, id)
if s.state.Claims[ck] {
return nil, errors.New("eventactions: quiz already cleared")
}
bundle, e := s.economy.Apply(identity, nil, r.Rewards)
if e != nil {
return nil, e
}
s.state.Claims[ck] = true
x := wire.AppendVarint(nil, 1, uid)
x = wire.AppendVarint(x, 2, g)
x = wire.AppendVarint(x, 3, id)
out := wire.AppendBytes(nil, 1, bundle)
return wire.AppendBytes(out, 2, x), nil
case "/TacticsBingoInfo":
uid := val(b, 2)
v, e := s.registry.Resolve(uid)
if e != nil {
return nil, e
}
if v.Type != 20 {
return nil, errors.New("eventactions: wrong tactics event")
}
out := wire.AppendVarint(nil, 1, uid)
out = wire.AppendVarint(out, 2, v.ID)
for _, id := range s.state.Tactics[uid] {
out = wire.AppendVarint(out, 3, id)
}
if !s.active(v) {
out = wire.AppendVarint(out, 4, 1)
}
return out, nil
case "/TacticsBingoDeckSave":
seen := map[uint64]bool{}
count := 0
err := wire.Walk(b, func(f wire.Field) error {
if f.Number != 2 || f.Type != 2 {
return nil
}
idx, id := val(f.Value, 1), val(f.Value, 2)
if idx == 0 || id == 0 || seen[idx] || s.owned == nil || !s.owned(idx, id) || val(f.Value, 4) > 14 || val(f.Value, 5) == 0 {
return errors.New("eventactions: invalid tactics deck")
}
seen[idx] = true
count++
return nil
})
if err != nil {
return nil, err
}
if count > 5 {
return nil, errors.New("eventactions: tactics deck too large")
}
s.state.Deck = append([]byte(nil), b...)
return nil, nil
default:
return s.voteHandle(path, b, identity)
}
}
func (s *Service) RecordTacticsClear(uid, stage uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
v, e := s.resolve(uid, 20)
if e != nil {
return e
}
group, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID)
if !ok {
return errors.New("eventactions: tactics group missing")
}
if _, ok = s.row("TacticsBingoTable", 4, 5, group.V(1), stage); !ok {
return errors.New("eventactions: invalid tactics stage")
}
for _, id := range s.state.Tactics[uid] {
if id == stage {
return nil
}
}
s.state.Tactics[uid] = append(s.state.Tactics[uid], stage)
sort.Slice(s.state.Tactics[uid], func(i, j int) bool { return s.state.Tactics[uid][i] < s.state.Tactics[uid][j] })
return s.save()
}
@@ -0,0 +1,139 @@
package eventactions
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"testing"
"time"
)
type economyStub struct {
calls int
costs, rewards []gamedata.Reward
}
func (e *economyStub) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) {
e.calls++
e.costs = append(e.costs, c...)
e.rewards = append(e.rewards, r...)
return []byte{10, 0}, nil
}
func row(v map[int]uint64) gamedata.EventActionRow { return gamedata.EventActionRow{Values: v} }
func setup(t *testing.T) (*Service, *economyStub, stateio.Store) {
t.Helper()
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
r := events.NewRegistry()
if e := r.Replace([]events.Schedule{{UID: 1, Type: 23, ID: 1, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(time.Hour).UnixMilli()}, {UID: 2, Type: 20, ID: 1, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(time.Hour).UnixMilli()}, {UID: 3, Type: 21, ID: 1, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(time.Hour).UnixMilli()}}); e != nil {
t.Fatal(e)
}
d := &gamedata.EventActionsDesign{Tables: map[string][]gamedata.EventActionRow{"VotingEventTable": {row(map[int]uint64{6: 1, 10: 1, 14: 1, 7: 1, 8: 6001, 9: 8, 1: 1, 2: 6002, 3: 8})}, "VotingCandidateTable": {row(map[int]uint64{1: 1, 2: 9})}, "VotingRoundTable": {row(map[int]uint64{2: 1, 3: 1})}, "TacticsBingoGroupTable": {row(map[int]uint64{3: 1, 1: 5, 4: 2})}, "TacticsBingoTable": {row(map[int]uint64{4: 5, 5: 1, 2: 99})}, "FieldSpawnEventTable": {row(map[int]uint64{4: 1, 5: 1, 2: 1})}, "FieldEventMonsterTable": {row(map[int]uint64{3: 1, 4: 1, 1: 3001, 5: 3009})}, "FieldEventDefaultTable": {row(map[int]uint64{4: 10, 5: 2, 12: 40})}}, SpawnRewards: map[[2]uint64]gamedata.Reward{{3009, 3001}: {Type: 4, Count: 10000}}}
economy := &economyStub{}
store := stateio.NewMemory()
s, e := Open(store, d, r, economy)
if e != nil {
t.Fatal(e)
}
s.now = func() time.Time { return now }
s.BeginSession("test")
return s, economy, store
}
func req(n uint64) []byte { return wire.AppendVarint(nil, 1, n) }
func TestVoteConsumesDesignCostRejectsRepeatAndExactReplay(t *testing.T) {
s, e, store := setup(t)
b := req(1)
b = wire.AppendVarint(b, 2, 9)
b = wire.AppendVarint(b, 4, 1)
_, reply, _, err := s.Handle("/CharVoteSave", b)
if err != nil {
t.Fatal(err)
}
if e.calls != 1 || e.costs[0].ID != 6001 {
t.Fatal("vote cost not applied")
}
next, err := Open(store, s.design, s.registry, e)
if err != nil {
t.Fatal(err)
}
next.now = s.now
next.BeginSession("test")
_, replay, _, err := next.Handle("/CharVoteSave", b)
if err != nil || !bytes.Equal(reply, replay) || e.calls != 1 {
t.Fatal("replay duplicated vote")
}
changed := wire.AppendVarint(b, 5, 1)
if _, _, _, err = next.Handle("/CharVoteSave", changed); err == nil {
t.Fatal("changed replay accepted")
}
b = wire.AppendVarint(req(2), 2, 9)
b = wire.AppendVarint(b, 4, 1)
if _, _, _, err = next.Handle("/CharVoteSave", b); err == nil {
t.Fatal("second normal vote accepted")
}
}
func TestTacticsBattleRequiresStageAndBindsWinReceipt(t *testing.T) {
s, _, _ := setup(t)
enter := req(1)
enter = wire.AppendVarint(enter, 4, 99)
enter = wire.AppendVarint(enter, 5, 29)
enter = wire.AppendVarint(enter, 8, 5)
enter = wire.AppendVarint(enter, 9, 1)
if _, err := s.EnterBattle(enter, "enter1"); err != nil {
t.Fatal(err)
}
end := wire.AppendVarint(req(2), 2, 1)
reply, err := s.CompleteBattle(end, "end2")
if err != nil || val(reply, 29) != 1 {
t.Fatal("stage not cleared")
}
replay, err := s.CompleteBattle(end, "end2")
if err != nil || !bytes.Equal(reply, replay) {
t.Fatal("battle retry lost")
}
loss := wire.AppendVarint(req(2), 2, 2)
if _, err = s.CompleteBattle(loss, "end2"); err == nil {
t.Fatal("changed battle result accepted")
}
}
func TestSpawnNeedsStartAndRejectsRecatch(t *testing.T) {
s, e, _ := setup(t)
reward := req(1)
for f, v := range map[int]uint64{2: 1, 3: 1, 4: 1, 5: 1, 6: 3} {
reward = wire.AppendVarint(reward, f, v)
}
if _, _, _, err := s.Handle("/FieldEventSpawnReward", reward); err == nil {
t.Fatal("unstarted catch accepted")
}
start := wire.AppendVarint(req(2), 2, 3)
start = wire.AppendVarint(start, 3, 1)
start = wire.AppendVarint(start, 4, 1)
if _, _, _, err := s.Handle("/FieldEventSpawnStart", start); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/FieldEventSpawnReward", reward); err != nil {
t.Fatal(err)
}
if e.calls != 1 || s.state.DailyNormal != 1 {
t.Fatal("catch not settled")
}
}
func TestVotingNextRoundAdvancesRealTopCandidates(t *testing.T) {
s, _, _ := setup(t)
s.design.Tables["VotingCandidateTable"] = append(s.design.Tables["VotingCandidateTable"], row(map[int]uint64{1: 1, 2: 10}))
s.design.Tables["VotingRoundTable"] = []gamedata.EventActionRow{row(map[int]uint64{2: 1, 3: 1, 1: 1}), row(map[int]uint64{2: 1, 3: 2, 4: 1})}
s.state.Votes[key(1, 1, 9)] = &vote{Round: 1, Candidate: 9, Normal: 1}
s.state.Votes[key(1, 1, 10)] = &vote{Round: 1, Candidate: 10, Additional: 2}
v, r, _ := s.voteEvent()
advanced := s.advancedCandidates(v, r, 2, s.candidates(r))
if len(advanced) != 1 || advanced[0] != 10 {
t.Fatalf("wrong advanced list %v", advanced)
}
s.state.Votes[key(1, 1, 9)].Additional = 1
rows := s.rankRows(1, 1, s.candidates(r))
if val(rows[0], 3) != 1 || val(rows[1], 3) != 1 {
t.Fatal("equal votes did not share rank")
}
}
+321
View File
@@ -0,0 +1,321 @@
package eventactions
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"errors"
"fmt"
"sort"
"time"
)
func (s *Service) voteEvent() (events.Schedule, gamedata.EventActionRow, bool) {
v, ok := s.find(23)
if !ok {
return v, gamedata.EventActionRow{}, false
}
r, ok := s.design.Row("VotingEventTable", 6, v.ID)
return v, r, ok
}
func (s *Service) rounds(v events.Schedule, r gamedata.EventActionRow) (uint64, []gamedata.EventActionRow) {
var rows []gamedata.EventActionRow
current := uint64(0)
for _, x := range s.design.Tables["VotingRoundTable"] {
if x.V(2) == r.V(14) {
rows = append(rows, x)
}
}
sort.Slice(rows, func(i, j int) bool { return rows[i].V(3) < rows[j].V(3) })
for _, x := range rows {
if s.now().UnixMilli() >= v.Start+int64(x.V(4))*86400000 {
current = x.V(3)
}
}
return current, rows
}
func (s *Service) candidates(r gamedata.EventActionRow) []uint64 {
var ids []uint64
for _, x := range s.design.Tables["VotingCandidateTable"] {
if x.V(1) == r.V(10) {
ids = append(ids, x.V(2))
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func contains(ids []uint64, id uint64) bool {
for _, v := range ids {
if v == id {
return true
}
}
return false
}
func voteWire(v *vote) []byte {
b := wire.AppendVarint(nil, 1, v.Round)
b = wire.AppendVarint(b, 2, v.Candidate)
b = wire.AppendVarint(b, 3, v.Normal+v.Additional)
b = wire.AppendVarint(b, 4, v.Normal)
return wire.AppendVarint(b, 5, v.Additional)
}
func (s *Service) voteHandle(path string, b []byte, identity string) ([]byte, error) {
v, r, ok := s.voteEvent()
if !ok {
if path == "/CharVoteInfo" || path == "/CharVoteSeasonRanking" {
return nil, nil
}
if path == "/CharVoteTotalRanking" {
return s.totalRanking(val(b, 2)), nil
}
return nil, errors.New("eventactions: voting event inactive")
}
round, rounds := s.rounds(v, r)
ids := s.candidates(r)
ids = s.advancedCandidates(v, r, round, ids)
switch path {
case "/CharVoteInfo":
out := wire.AppendVarint(nil, 1, round)
for _, id := range ids {
out = wire.AppendVarint(out, 2, id)
}
for k, x := range s.state.Votes {
var uid, ro, c uint64
if _, e := fmt.Sscanf(k, "%d/%d/%d", &uid, &ro, &c); e == nil && uid == v.UID {
out = wire.AppendBytes(out, 3, voteWire(x))
}
}
for k := range s.state.VoteRewards {
var uid, id uint64
if _, e := fmt.Sscanf(k, "%d/%d", &uid, &id); e == nil && uid == v.UID {
x := wire.AppendVarint(nil, 1, v.ID)
x = wire.AppendVarint(x, 2, id)
out = wire.AppendBytes(out, 4, x)
}
}
for _, id := range ids {
if s.state.Favorites[id] {
out = wire.AppendVarint(out, 5, id)
}
if s.state.NormalVoted[key(v.UID, id)] {
out = wire.AppendVarint(out, 6, id)
}
}
out = wire.AppendVarint(out, 7, uint64(s.now().UTC().Truncate(24*time.Hour).Add(24*time.Hour).UnixMilli()))
for i, x := range rounds {
start := v.Start + int64(x.V(4))*86400000
end := v.End
if i+1 < len(rounds) {
end = v.Start + int64(rounds[i+1].V(4))*86400000
}
a := wire.AppendVarint(nil, 1, x.V(3))
a = wire.AppendVarint(a, 2, uint64(start))
a = wire.AppendVarint(a, 3, uint64(end))
out = wire.AppendBytes(out, 9, a)
}
return out, nil
case "/CharVoteFavoriteAdd", "/CharVoteFavoriteDelete":
id := val(b, 2)
if !contains(ids, id) {
return nil, errors.New("eventactions: unknown candidate")
}
if path == "/CharVoteFavoriteAdd" {
s.state.Favorites[id] = true
} else {
delete(s.state.Favorites, id)
}
return nil, nil
case "/CharVoteSave":
id, count, typ := val(b, 2), val(b, 4), val(b, 5)
if !contains(ids, id) || count == 0 || count > 2147483647 || typ > 1 || round == 0 {
return nil, errors.New("eventactions: invalid vote")
}
normalKey := key(v.UID, id)
if typ == 0 && (s.state.NormalVoted[normalKey] || count != 1) {
return nil, errors.New("eventactions: daily normal vote exhausted")
}
cost := gamedata.Reward{Type: r.V(9), ID: r.V(8), Count: r.V(7) * count}
if typ == 1 {
cost = gamedata.Reward{Type: r.V(3), ID: r.V(2), Count: r.V(1) * count}
}
// A first vote may use the additional ticket when the normal ticket
// is unavailable; the client still sends vote_type=0.
if typ == 0 {
var submittedID uint64
_ = wire.Walk(b, func(f wire.Field) error {
if f.Number == 3 && f.Type == 2 {
submittedID = val(f.Value, 2)
}
return nil
})
if submittedID == r.V(2) {
cost = gamedata.Reward{Type: r.V(3), ID: r.V(2), Count: r.V(1) * count}
}
}
if cost.Type == 0 || cost.Count == 0 {
return nil, errors.New("eventactions: missing vote cost")
}
vk := key(v.UID, round, id)
current := s.state.Votes[vk]
if current == nil {
current = &vote{Round: round, Candidate: id}
}
var total uint64
for k, x := range s.state.Votes {
var uid, ro, c uint64
if _, e := fmt.Sscanf(k, "%d/%d/%d", &uid, &ro, &c); e == nil && uid == v.UID {
total += x.Normal + x.Additional
}
}
var rewards []gamedata.Reward
var newIDs []uint64
for _, rr := range s.design.Tables["VotingCountRewardTable"] {
if rr.V(1) == r.V(11) && rr.V(6) <= total+count && !s.state.VoteRewards[key(v.UID, rr.V(2))] {
rewards = append(rewards, rr.Rewards...)
newIDs = append(newIDs, rr.V(2))
}
}
bundle, e := s.economy.Apply(identity, []gamedata.Reward{cost}, rewards)
if e != nil {
return nil, e
}
s.state.Votes[vk] = current
if typ == 0 {
current.Normal += count
s.state.NormalVoted[normalKey] = true
} else {
current.Additional += count
}
out := wire.AppendBytes(nil, 1, bundle)
for _, id := range newIDs {
s.state.VoteRewards[key(v.UID, id)] = true
x := wire.AppendVarint(nil, 1, v.ID)
x = wire.AppendVarint(x, 2, id)
out = wire.AppendBytes(out, 2, x)
}
out = wire.AppendBytes(out, 4, voteWire(current))
return out, nil
case "/CharVoteRanking":
wanted := val(b, 2)
if wanted == 0 {
wanted = round
}
out := wire.AppendVarint(nil, 1, v.ID)
out = wire.AppendVarint(out, 2, wanted)
for _, x := range s.rankRows(v.UID, wanted, ids) {
out = wire.AppendBytes(out, 3, x)
}
return out, nil
case "/CharVoteTotalRanking":
return s.totalRanking(val(b, 2)), nil
case "/CharVoteSeasonRanking":
var out []byte
for _, schedule := range s.registry.List() {
if schedule.Type != 23 {
continue
}
for _, id := range ids {
var total uint64
for k, x := range s.state.Votes {
var uid, ro, c uint64
if _, e := fmt.Sscanf(k, "%d/%d/%d", &uid, &ro, &c); e == nil && uid == schedule.UID && c == id {
total += x.Normal + x.Additional
}
}
if total == 0 {
continue
}
x := wire.AppendVarint(nil, 1, schedule.ID)
x = wire.AppendVarint(x, 2, id)
x = wire.AppendVarint(x, 3, total)
x = wire.AppendVarint(x, 4, total)
out = wire.AppendBytes(out, 1, x)
}
}
return out, nil
}
return nil, errors.New("eventactions: unknown voting operation")
}
func (s *Service) rankRows(uid, round uint64, ids []uint64) [][]byte {
type result struct{ id, count, last uint64 }
var rows []result
var totals map[uint64]uint64
if s.voteTotals != nil {
totals, _ = s.voteTotals(uid, round)
}
for _, id := range ids {
r := result{id: id}
for k, x := range s.state.Votes {
var u, ro, c uint64
if _, e := fmt.Sscanf(k, "%d/%d/%d", &u, &ro, &c); e == nil && u == uid && c == id && (round == 0 || round == ro) {
r.count += x.Normal + x.Additional
if ro > r.last {
r.last = ro
}
}
}
if totals != nil {
r.count = totals[id]
}
rows = append(rows, r)
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].count == rows[j].count {
return rows[i].id < rows[j].id
}
return rows[i].count > rows[j].count
})
var out [][]byte
var lastCount, rank uint64
for i, r := range rows {
if i == 0 || r.count != lastCount {
rank = uint64(i + 1)
}
lastCount = r.count
x := wire.AppendVarint(nil, 1, r.id)
x = wire.AppendVarint(x, 2, r.count)
x = wire.AppendVarint(x, 3, rank)
x = wire.AppendVarint(x, 4, r.last)
out = append(out, x)
}
return out
}
func (s *Service) advancedCandidates(v events.Schedule, event gamedata.EventActionRow, round uint64, ids []uint64) []uint64 {
for previous := uint64(1); previous < round; previous++ {
var count uint64
for _, r := range s.design.Tables["VotingRoundTable"] {
if r.V(2) == event.V(14) && r.V(3) == previous {
count = r.V(1)
}
}
if count == 0 {
continue
}
ranked := s.rankRows(v.UID, previous, ids)
var next []uint64
for _, b := range ranked {
if val(b, 3) <= count {
next = append(next, val(b, 1))
}
}
ids = next
}
return ids
}
func (s *Service) totalRanking(event uint64) []byte {
out := wire.AppendVarint(nil, 1, event)
for _, v := range s.registry.List() {
if v.Type != 23 || v.ID != event {
continue
}
r, ok := s.design.Row("VotingEventTable", 6, event)
if !ok {
continue
}
for _, row := range s.rankRows(v.UID, 0, s.candidates(r)) {
out = wire.AppendBytes(out, 2, row)
}
}
return out
}
+400
View File
@@ -0,0 +1,400 @@
package eventexchange
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"math"
"math/big"
"sort"
"strconv"
"sync"
"time"
)
type Runtime interface {
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
ConsumeAndGrant(string, []player.Item, []gamedata.BattleReward) ([]byte, error)
}
type progress struct {
Group, Page, Key, Free uint64
Reset int64
Counts map[string]uint64
}
type receipt struct {
Fingerprint, Response []byte
Code int
}
type snapshot struct {
Version string `json:"version"`
Progress map[string]progress `json:"progress"`
Receipts map[string]receipt `json:"receipts"`
}
type Service struct {
mu sync.Mutex
store stateio.Store
design *gamedata.EventExchangeCatalog
registry events.Resolver
runtime Runtime
now func() time.Time
draw func(uint64) (uint64, error)
}
func Open(store stateio.Store, design *gamedata.EventExchangeCatalog, registry events.Resolver, runtime Runtime) (*Service, error) {
if store == nil || design == nil || registry == nil || runtime == nil {
return nil, fmt.Errorf("eventexchange: invalid configuration")
}
s := &Service{store: store, design: design, registry: registry, runtime: runtime, now: time.Now}
s.draw = func(n uint64) (uint64, error) {
if n == 0 {
return 0, fmt.Errorf("eventexchange: empty reward pool")
}
v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n))
if e != nil {
return 0, e
}
return v.Uint64(), nil
}
_, err := s.load()
return s, err
}
func (s *Service) load() (snapshot, error) {
st := snapshot{versionconfig.State(), map[string]progress{}, map[string]receipt{}}
b, err := s.store.Load("eventexchange")
if err != nil {
return st, err
}
if b == nil {
return st, nil
}
if err = stateio.RequireExactJSONObject(b, "version", "progress", "receipts"); err != nil {
return st, err
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.DisallowUnknownFields()
if err = dec.Decode(&st); err != nil {
return st, err
}
if st.Version != versionconfig.State() || st.Progress == nil || st.Receipts == nil {
return st, fmt.Errorf("eventexchange: incompatible state")
}
for uid, p := range st.Progress {
n, e := strconv.ParseUint(uid, 10, 64)
g, ok := s.design.Groups[p.Group]
if e != nil || n == 0 || !ok || p.Page < g.StartPage || (!g.Repeat && p.Page > g.EndPage) || p.Counts == nil || p.Free > g.FreeCount {
return st, fmt.Errorf("eventexchange: invalid saved progress")
}
known := map[string]uint64{}
for _, entry := range g.Page(p.Page) {
known[strconv.FormatUint(entry.ID, 10)] = entry.SetCount
}
for id, count := range p.Counts {
limit, ok := known[id]
if !ok || count > limit {
return st, fmt.Errorf("eventexchange: invalid reward count")
}
}
}
return st, nil
}
func (s *Service) save(st snapshot) error {
b, err := json.Marshal(st)
if err != nil {
return err
}
return s.store.Save("eventexchange", b)
}
func (s *Service) active(uid uint64) (events.Schedule, gamedata.EventExchangeGroup, error) {
a, e := s.registry.Resolve(uid)
if e != nil {
return a, gamedata.EventExchangeGroup{}, e
}
g, ok := s.design.Groups[a.ID]
now := s.now().UnixMilli()
if !ok || a.Type != 7 || now < a.Start || now >= a.End {
return a, g, fmt.Errorf("eventexchange: event unavailable")
}
return a, g, nil
}
func (s *Service) init(st *snapshot, a events.Schedule, g gamedata.EventExchangeGroup) progress {
k := strconv.FormatUint(a.UID, 10)
p, ok := st.Progress[k]
if !ok {
p = progress{Group: g.ID, Page: g.StartPage, Free: g.FreeCount, Counts: map[string]uint64{}}
}
if g.FreeType == 2 {
now := s.now()
if p.Reset <= now.UnixMilli() {
p.Free = g.FreeCount
p.Reset = now.UTC().Truncate(24 * time.Hour).Add(24 * time.Hour).UnixMilli()
}
}
return p
}
func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) {
return s.HandleSession(path, req, "local")
}
func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) {
code, ok := map[string]int{"/EventExchangeInfo": 183, "/EventExchangeReward": 184, "/EventExchangeNextPageOpen": 185}[path]
if !ok {
return 0, nil, false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
seq, found, err := wire.Varint(req, 1)
if err != nil || !found || seq == 0 || seq > math.MaxInt32 {
return code, nil, true, fmt.Errorf("eventexchange: invalid sequence")
}
st, err := s.load()
if err != nil {
return code, nil, true, err
}
key := session + ":" + strconv.FormatUint(seq, 10)
fingerprint := append([]byte(path), req...)
if r, ok := st.Receipts[key]; ok {
if !bytes.Equal(fingerprint, r.Fingerprint) {
return code, nil, true, fmt.Errorf("eventexchange: sequence conflict")
}
return r.Code, r.Response, true, nil
}
var out []byte
if path == "/EventExchangeInfo" {
rows := s.registry.List()
sort.Slice(rows, func(i, j int) bool { return rows[i].UID < rows[j].UID })
for _, a := range rows {
_, g, e := s.active(a.UID)
if e != nil {
continue
}
p := s.init(&st, a, g)
st.Progress[strconv.FormatUint(a.UID, 10)] = p
out = wire.AppendBytes(out, 1, progressWire(a.UID, p))
for _, entry := range g.Page(p.Page) {
out = wire.AppendBytes(out, 2, countWire(a.UID, g.ID, entry.ID, p.Counts[strconv.FormatUint(entry.ID, 10)]))
}
}
if err = s.save(st); err != nil {
return code, nil, true, err
}
return code, out, true, nil
}
uid, _, e := wire.Varint(req, 2)
if e != nil || uid == 0 {
return code, nil, true, fmt.Errorf("eventexchange: invalid event UID")
}
a, g, err := s.active(uid)
if err != nil {
return code, nil, true, err
}
p := s.init(&st, a, g)
if path == "/EventExchangeNextPageOpen" {
group, _, e := wire.Varint(req, 3)
if e != nil || group != g.ID {
return code, nil, true, fmt.Errorf("eventexchange: group mismatch")
}
if !canAdvance(g, p) {
return code, nil, true, fmt.Errorf("eventexchange: next page locked")
}
if err = advance(g, &p); err != nil {
return code, nil, true, err
}
} else {
count, _, e := wire.Varint(req, 3)
if e != nil || count == 0 || count > 10 {
return code, nil, true, fmt.Errorf("eventexchange: count must be 1..10")
}
uses, e := parseUses(req)
if e != nil {
return code, nil, true, e
}
free := len(uses) == 0
if free {
if g.FreeCount == 0 || p.Free < count {
return code, nil, true, fmt.Errorf("eventexchange: free draws exhausted")
}
p.Free -= count
} else {
if g.Cost.Count > math.MaxInt32/count {
return code, nil, true, fmt.Errorf("eventexchange: cost overflow")
}
want := g.Cost.Count * count
var total uint64
for _, item := range uses {
if item.Type != g.Cost.Type || item.ID != g.Cost.ID || item.Count == 0 || item.Count > math.MaxInt32 || total > math.MaxInt32-item.Count {
return code, nil, true, fmt.Errorf("eventexchange: invalid consume items")
}
total += item.Count
}
if total != want {
return code, nil, true, fmt.Errorf("eventexchange: consume count mismatch")
}
}
var rewards []gamedata.Reward
var changed []byte
for n := uint64(0); n < count; n++ {
entries := g.Page(p.Page)
var weights []uint64
var sum uint64
for _, entry := range entries {
remaining := entry.SetCount - p.Counts[strconv.FormatUint(entry.ID, 10)]
weight := entry.Ratio
var draws uint64
for _, c := range p.Counts {
draws += c
}
if g.UnlockRatio > 0 && draws+1 < g.UnlockRatio {
weight = entry.LimitedRatio
}
if remaining == 0 {
weight = 0
}
if sum > math.MaxUint64-weight {
return code, nil, true, fmt.Errorf("eventexchange: weight sum overflow")
}
sum += weight
weights = append(weights, weight)
}
if sum == 0 {
return code, nil, true, fmt.Errorf("eventexchange: reward pool exhausted")
}
v, e := s.draw(sum)
if e != nil || v >= sum {
return code, nil, true, fmt.Errorf("eventexchange: random source failure")
}
for i, entry := range entries {
if v < weights[i] {
id := strconv.FormatUint(entry.ID, 10)
p.Counts[id]++
if entry.KeyType == 1 && p.Counts[id] == entry.SetCount {
p.Key++
}
rewards = append(rewards, entry.Reward)
changed = wire.AppendBytes(changed, 2, countWire(uid, g.ID, entry.ID, p.Counts[id]))
break
}
v -= weights[i]
}
if exhausted(g, p) && (p.Page < g.EndPage || g.Repeat) {
if e := advance(g, &p); e != nil {
return code, nil, true, e
}
}
}
var bundle []byte
if free {
bundle, err = s.runtime.Apply("eventexchange:"+key, nil, rewards)
} else {
currency := g.Cost.Type == 2 || g.Cost.Type == 3 || g.Cost.Type == 4 || g.Cost.Type == 12 || g.Cost.Type == 20
if currency {
cost := g.Cost
cost.Count *= count
bundle, err = s.runtime.Apply("eventexchange:"+key, []gamedata.Reward{cost}, rewards)
} else {
rs := make([]gamedata.BattleReward, len(rewards))
for i, r := range rewards {
rs[i] = gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count}
}
bundle, err = s.runtime.ConsumeAndGrant("eventexchange:"+key, uses, rs)
}
}
if err != nil {
return code, nil, true, err
}
out = wire.AppendBytes(out, 1, bundle)
out = append(out, changed...)
out = wire.AppendBytes(out, 3, progressWire(uid, p))
}
st.Progress[strconv.FormatUint(uid, 10)] = p
st.Receipts[key] = receipt{fingerprint, out, code}
if err = s.save(st); err != nil {
return code, nil, true, err
}
return code, out, true, nil
}
func exhausted(g gamedata.EventExchangeGroup, p progress) bool {
for _, e := range g.Page(p.Page) {
if p.Counts[strconv.FormatUint(e.ID, 10)] < e.SetCount {
return false
}
}
return true
}
func canAdvance(g gamedata.EventExchangeGroup, p progress) bool {
if p.Page >= g.EndPage && !g.Repeat {
return false
}
var keys uint64
for _, e := range g.Page(p.Page) {
if e.KeyType == 1 {
keys++
}
}
return exhausted(g, p) || (keys > 0 && p.Key >= keys)
}
func advance(g gamedata.EventExchangeGroup, p *progress) error {
if p.Page >= math.MaxInt32 {
return fmt.Errorf("eventexchange: page overflow")
}
if p.Page >= g.EndPage && !g.Repeat {
return fmt.Errorf("eventexchange: final page")
}
p.Page++
p.Key = 0
p.Counts = map[string]uint64{}
return nil
}
func progressWire(uid uint64, p progress) []byte {
var out []byte
for _, f := range []struct {
n int
v uint64
}{{1, uid}, {2, p.Group}, {3, p.Page}, {4, p.Key}, {5, p.Free}, {6, uint64(p.Reset)}} {
if f.v > 0 {
out = wire.AppendVarint(out, f.n, f.v)
}
}
return out
}
func countWire(uid, group, id, count uint64) []byte {
b := wire.AppendVarint(nil, 1, uid)
b = wire.AppendVarint(b, 2, group)
b = wire.AppendVarint(b, 3, id)
if count > 0 {
b = wire.AppendVarint(b, 4, count)
}
return b
}
func parseUses(req []byte) ([]player.Item, error) {
var out []player.Item
seen := map[uint64]bool{}
err := wire.Walk(req, func(f wire.Field) error {
if f.Number != 4 {
return nil
}
if f.Type != 2 {
return fmt.Errorf("eventexchange: malformed use item")
}
var item player.Item
for n, p := range map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count} {
v, _, e := wire.Varint(f.Value, n)
if e != nil {
return e
}
*p = v
}
if item.Type == 0 || item.Count == 0 || item.InvenIndex > math.MaxInt64 || seen[item.InvenIndex] {
return fmt.Errorf("eventexchange: invalid or duplicate consume item")
}
seen[item.InvenIndex] = true
out = append(out, item)
return nil
})
return out, err
}
@@ -0,0 +1,131 @@
package eventexchange
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"fmt"
"testing"
"time"
)
type fakeRuntime struct {
paid, grants uint64
fail bool
uses []player.Item
}
func (r *fakeRuntime) Apply(_ string, costs, rewards []gamedata.Reward) ([]byte, error) {
if r.fail {
return nil, fmt.Errorf("failed")
}
for _, c := range costs {
r.paid += c.Count
}
r.grants += uint64(len(rewards))
return []byte{10, 0}, nil
}
func (r *fakeRuntime) ConsumeAndGrant(_ string, uses []player.Item, rs []gamedata.BattleReward) ([]byte, error) {
if r.fail {
return nil, fmt.Errorf("failed")
}
r.uses = uses
r.grants += uint64(len(rs))
return []byte{10, 0}, nil
}
func exchangeFixture(t *testing.T) (*Service, *fakeRuntime) {
t.Helper()
reg := events.NewRegistry()
if e := reg.Replace([]events.Schedule{{UID: 99, Type: 7, ID: 1, Start: 1, End: 9999999999999}}); e != nil {
t.Fatal(e)
}
g := gamedata.EventExchangeGroup{ID: 1, StartPage: 1, EndPage: 2, Repeat: true, Cost: gamedata.Reward{Type: 8, ID: 77, Count: 2}, FreeCount: 1, FreeType: 2, Entries: []gamedata.EventExchangeEntry{{ID: 1, Page: 1, KeyType: 1, Ratio: 1, SetCount: 1, Reward: gamedata.Reward{Type: 4, Count: 100}}, {ID: 2, Page: 1, Ratio: 1, SetCount: 1, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 1}}, {ID: 3, Page: 2, Ratio: 1, SetCount: 1, Reward: gamedata.Reward{Type: 4, Count: 5}}}}
r := &fakeRuntime{}
s, e := Open(stateio.NewMemory(), &gamedata.EventExchangeCatalog{Groups: map[uint64]gamedata.EventExchangeGroup{1: g}}, reg, r)
if e != nil {
t.Fatal(e)
}
s.now = func() time.Time { return time.UnixMilli(10000) }
s.draw = func(uint64) (uint64, error) { return 0, nil }
return s, r
}
func exchangeRequest(seq, count uint64, paid bool) []byte {
b := wire.AppendVarint(nil, 1, seq)
b = wire.AppendVarint(b, 2, 99)
b = wire.AppendVarint(b, 3, count)
if paid {
item := wire.AppendVarint(nil, 1, 123)
item = wire.AppendVarint(item, 2, 77)
item = wire.AppendVarint(item, 3, 8)
item = wire.AppendVarint(item, 4, count*2)
b = wire.AppendBytes(b, 4, item)
}
return b
}
func TestFreePaidReplayAndKeyAdvance(t *testing.T) {
s, r := exchangeFixture(t)
req := exchangeRequest(1, 1, false)
_, a, _, e := s.HandleSession("/EventExchangeReward", req, "session")
if e != nil {
t.Fatal(e)
}
_, b, _, e := s.HandleSession("/EventExchangeReward", req, "session")
if e != nil || !bytes.Equal(a, b) || r.grants != 1 {
t.Fatalf("replay %v %d", e, r.grants)
}
if _, _, _, e = s.HandleSession("/EventExchangeReward", exchangeRequest(2, 1, false), "session"); e == nil {
t.Fatal("free reused")
}
next := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 99), 3, 1)
if _, _, _, e = s.HandleSession("/EventExchangeNextPageOpen", next, "session"); e != nil {
t.Fatal(e)
}
if _, _, _, e = s.HandleSession("/EventExchangeReward", exchangeRequest(4, 1, true), "session"); e != nil {
t.Fatal(e)
}
st, _ := s.load()
if st.Progress["99"].Page != 3 || len(r.uses) != 1 || r.uses[0].InvenIndex != 123 {
t.Fatalf("advance %+v uses %+v", st, r.uses)
}
}
func TestInvalidCostAndFailedGrantKeepPool(t *testing.T) {
s, r := exchangeFixture(t)
bad := exchangeRequest(1, 1, true)
bad = wire.AppendBytes(bad, 4, wire.AppendVarint(nil, 3, 8))
if _, _, _, e := s.HandleSession("/EventExchangeReward", bad, "session"); e == nil {
t.Fatal("invalid cost accepted")
}
r.fail = true
if _, _, _, e := s.HandleSession("/EventExchangeReward", exchangeRequest(2, 1, false), "session"); e == nil {
t.Fatal("failed grant accepted")
}
st, _ := s.load()
if len(st.Progress) != 0 || len(st.Receipts) != 0 {
t.Fatal("failed draw saved progress")
}
}
func TestLuckyPrizeLockedUntilUnlockDraw(t *testing.T) {
s, _ := exchangeFixture(t)
g := s.design.Groups[1]
g.UnlockRatio = 3
g.Entries[0].LimitedRatio = 0
g.Entries[1].LimitedRatio = 1
g.Entries[1].SetCount = 4
s.design.Groups[1] = g
for i := uint64(1); i <= 3; i++ {
if _, _, _, e := s.HandleSession("/EventExchangeReward", exchangeRequest(i, 1, true), "session"); e != nil {
t.Fatal(e)
}
st, _ := s.load()
count := st.Progress["99"].Counts["1"]
if i < 3 && count != 0 {
t.Fatal("prize unlocked early")
}
if i == 3 && count != 1 {
t.Fatal("prize not unlocked")
}
}
}
+46
View File
@@ -0,0 +1,46 @@
# Seasonal minigame rules
The registry supplies event identity and dates. This package never creates,
extends or activates a season. Each schedule UID owns independent progress.
Queries initialize a valid new board and return persisted progress. Mutations
require a live matching schedule. Requests are identified by session, path and
sequence; identical retries replay the entire stored response and changed
retries fail.
GameData supplies costs, move ranges, board size, reward sets, completion
thresholds, free roulette draws, special slots and pity thresholds. Client
protobuf and packet callbacks supply field shapes. RouletteInfo capture is a
comparison sample, not runtime data.
LocalText 460038 describes Bingo opening rewards and completed-line rewards.
LocalText 460040 describes puzzle tile rewards, word completion rewards and
the final stage's prohibition on renewal. These meanings drive the following
server rules:
- Dice starts at the first designed scaffold. Each configured controller
samples uniformly inside its inclusive range. Crossing the end advances
completion count; all newly crossed completion rewards and the final
landing scaffold reward settle together.
- Bingo shuffles the reward IDs in its current clear-count reward set.
Opening samples unopened zero-based positions without replacement.
Protocol line types are diagonal 0, row 1 and column 2. Each line pays once
per board. Finishing advances count and creates a fresh board; after the
last designed reward tier, that tier repeats.
- Puzzle starts at designed stage 1. Reward IDs form the board. Opening a
tile pays its reward; opening all members of a word pays that word once.
Renewal requires all current-stage word members, as the client checks,
and advances only while another designed stage exists. Unopened incidental
tiles may be abandoned on renewal. The final stage cannot renew.
- Roulette free draws reset at UTC midnight (KST 09:00); accumulated tries
and special/pity state survive daily reset. Free and paid draws enforce
their separate costs. Weighted sampling uses the design's primary weights,
then secondary weights after a special reward where provided. Pity forces
a designed special slot. Accumulated threshold rewards pay when crossed.
Randomness uses crypto/rand and can be injected in package tests. All submitted
consumed item records must match the server-computed design cost. State is
cloned, an entire batch is simulated and validated, and then economic rewards
and the snapshot are saved. Production must run this handler under the
account repository's request transaction; failed requests roll back item,
wallet and snapshot changes together. The main server passes an entry-backed
snapshot store, so no existing domain core layout changes.
+402
View File
@@ -0,0 +1,402 @@
// Package eventgames implements this server's persistent seasonal minigames.
package eventgames
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"crypto/rand"
"encoding/binary"
"encoding/json"
"fmt"
"math"
"math/big"
"strconv"
"strings"
"sync"
"time"
)
type Resolver interface {
Resolve(uint64) (events.Schedule, error)
}
type Rewards interface {
ConsumeAndGrant(string, []player.Item, []gamedata.BattleReward) ([]byte, error)
}
type GameState struct {
UID, Type, ID, Position, Clear, Free, Reset, Tries, SinceSpecial uint64
Board, Opened, Lines []uint64
Special bool
}
type reply struct{ Request, Body []byte }
type snapshot struct {
Version int `json:"version"`
Games map[string]GameState `json:"games"`
Replies map[string]reply `json:"replies"`
}
type Service struct {
mu sync.Mutex
store stateio.Store
resolver Resolver
rewards Rewards
state snapshot
load func(uint64, uint64) (*gamedata.EventGame, error)
sample func(uint64) (uint64, error)
now func() time.Time
}
func Open(store stateio.Store, root, version string, resolver Resolver, rewards Rewards) (*Service, error) {
if store == nil || resolver == nil || rewards == nil {
return nil, fmt.Errorf("eventgames: incomplete runtime")
}
s := &Service{store: store, resolver: resolver, rewards: rewards, now: time.Now, state: snapshot{1, map[string]GameState{}, map[string]reply{}}}
cache := map[string]*gamedata.EventGame{}
s.load = func(kind, id uint64) (*gamedata.EventGame, error) {
k := fmt.Sprintf("%d:%d", kind, id)
if d := cache[k]; d != nil {
return d, nil
}
d, e := gamedata.LoadEventGame(root, version, kind, id)
if e == nil {
cache[k] = d
}
return d, e
}
s.sample = func(max uint64) (uint64, error) {
if max == 0 {
return 0, fmt.Errorf("eventgames: empty sample")
}
n, e := rand.Int(rand.Reader, new(big.Int).SetUint64(max))
if e != nil {
return 0, e
}
return n.Uint64(), nil
}
raw, e := store.Load("eventgames")
if e != nil {
return nil, e
}
if raw != nil {
if e = stateio.RequireExactJSONObject(raw, "version", "games", "replies"); e != nil {
return nil, e
}
if e = json.Unmarshal(raw, &s.state); e != nil {
return nil, e
}
if s.state.Version != 1 || s.state.Games == nil || s.state.Replies == nil {
return nil, fmt.Errorf("eventgames: incompatible state")
}
}
for key, g := range s.state.Games {
d, e := s.load(g.Type, g.ID)
if e != nil {
return nil, e
}
if e = validateSaved(key, g, d); e != nil {
return nil, e
}
}
return s, nil
}
func nums(b []byte, n int) ([]uint64, error) {
var out []uint64
e := wire.Walk(b, func(f wire.Field) error {
if f.Number != n {
return nil
}
if f.Type == 0 {
v, _ := binary.Uvarint(f.Value)
out = append(out, v)
return nil
}
if f.Type != 2 {
return wire.ErrMalformed
}
for p := f.Value; len(p) > 0; {
v, k := binary.Uvarint(p)
if k <= 0 {
return wire.ErrMalformed
}
out = append(out, v)
p = p[k:]
}
return nil
})
return out, e
}
func scalar(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v }
func contains(a []uint64, v uint64) bool {
for _, x := range a {
if x == v {
return true
}
}
return false
}
func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) {
return s.HandleSession(path, req, "local")
}
var routes = map[string]struct {
Code int
Kind uint64
}{"/MiniGameBoardInfo": {223, 12}, "/MiniGameBoardPlay": {224, 12}, "/MiniGameBingoInfo": {256, 13}, "/MiniGameBingoPlay": {257, 13}, "/MiniPuzzleInfo": {343, 17}, "/MiniPuzzleOpen": {344, 17}, "/MiniPuzzleAllOpen": {345, 17}, "/MiniPuzzleRenew": {346, 17}, "/MiniGameRouletteInfo": {415, 19}, "/MiniGameRouletteDraw": {416, 19}}
func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) {
r, ok := routes[path]
if !ok {
return 0, nil, false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
fail := func(e error) (int, []byte, bool, error) { return r.Code, nil, true, e }
if e := wire.Walk(req, func(f wire.Field) error {
if f.Type == 0 && f.Number != 3 {
v, _ := binary.Uvarint(f.Value)
if v > math.MaxInt32 {
return wire.ErrMalformed
}
}
return nil
}); e != nil {
return fail(e)
}
seq := scalar(req, 1)
if seq == 0 || seq > math.MaxInt32 {
return fail(fmt.Errorf("eventgames: invalid sequence"))
}
key := fmt.Sprintf("%s:%s:%d", session, path, seq)
if v, ok := s.state.Replies[key]; ok {
if !bytes.Equal(v.Request, req) {
return fail(fmt.Errorf("eventgames: changed request retry"))
}
return r.Code, v.Body, true, nil
}
raw, _ := json.Marshal(s.state)
var next snapshot
_ = json.Unmarshal(raw, &next)
uids, e := nums(req, 2)
if e != nil || len(uids) == 0 && !strings.HasSuffix(path, "Info") {
return fail(fmt.Errorf("eventgames: missing schedule"))
}
if len(uids) == 0 {
return r.Code, nil, true, nil
}
if !strings.HasSuffix(path, "Info") && len(uids) != 1 {
return fail(fmt.Errorf("eventgames: operation requires one schedule"))
}
var out []byte
seen := map[uint64]bool{}
for _, uid := range uids {
if uid == 0 || seen[uid] {
return fail(fmt.Errorf("eventgames: invalid duplicate schedule"))
}
seen[uid] = true
c, e := s.resolver.Resolve(uid)
if e != nil {
return fail(e)
}
if c.UID != uid || c.Type != r.Kind {
return fail(fmt.Errorf("eventgames: schedule/game mismatch"))
}
d, e := s.load(c.Type, c.ID)
if e != nil {
return fail(e)
}
k := strconv.FormatUint(uid, 10)
g, exists := next.Games[k]
if !exists {
g = GameState{UID: uid, Type: c.Type, ID: c.ID, Free: d.Free}
if e = s.initialize(&g, d); e != nil {
return fail(e)
}
}
if g.Type != c.Type || g.ID != c.ID {
return fail(fmt.Errorf("eventgames: saved game identity changed"))
}
now := uint64(s.now().UnixMilli())
if c.Type == 19 && g.Reset <= now {
g.Free = d.Free
g.Reset = (now/86400000 + 1) * 86400000
}
if strings.HasSuffix(path, "Info") {
out = wire.AppendBytes(out, 1, stateWire(g, d))
next.Games[k] = g
continue
}
if int64(now) < c.Start || int64(now) >= c.End {
return fail(fmt.Errorf("eventgames: event is closed"))
}
before := stateWire(g, d)
body, cost, rewards, e := s.play(path, req, &g, d)
if e != nil {
return fail(e)
}
consumeField := 3
if path == "/MiniPuzzleOpen" {
consumeField = 4
}
if path == "/MiniGameRouletteDraw" {
consumeField = 5
}
items, e := consume(req, consumeField, d, cost)
if e != nil {
return fail(e)
}
bundles := map[int][]byte{}
fields := []int{3, 4, 5, 6, 7}
if c.Type == 19 {
fields = []int{2, 3}
}
for _, field := range fields {
rr := rewards[field]
if len(rr) == 0 {
continue
}
consumed := []player.Item(nil)
if len(items) > 0 {
consumed = items
items = nil
}
b, e := s.rewards.ConsumeAndGrant(fmt.Sprintf("eventgames:%s:%d", key, field), consumed, rr)
if e != nil {
return fail(e)
}
bundles[field] = b
}
if len(items) > 0 {
if _, e = s.rewards.ConsumeAndGrant("eventgames:"+key+":cost", items, nil); e != nil {
return fail(e)
}
}
switch c.Type {
case 12:
body = wire.AppendBytes(body, 1, stateWire(g, d))
case 13, 17:
body = wire.AppendBytes(body, 1, before)
body = wire.AppendBytes(body, 2, stateWire(g, d))
case 19:
body = wire.AppendBytes(body, 1, stateWire(g, d))
}
for _, field := range fields {
if b := bundles[field]; len(b) > 0 {
body = wire.AppendBytes(body, field, b)
}
}
out = body
next.Games[k] = g
}
next.Replies[key] = reply{append([]byte(nil), req...), out}
payload, e := json.Marshal(next)
if e != nil {
return fail(e)
}
if e = s.store.Save("eventgames", payload); e != nil {
return fail(e)
}
s.state = next
return r.Code, out, true, nil
}
func consume(req []byte, n int, d *gamedata.EventGame, count uint64) ([]player.Item, error) {
var out []player.Item
total := uint64(0)
seen := map[uint64]bool{}
e := wire.Walk(req, func(f wire.Field) error {
if f.Number != n {
return nil
}
if f.Type != 2 {
return wire.ErrMalformed
}
id, typ, qty, index := scalar(f.Value, 2), scalar(f.Value, 3), scalar(f.Value, 4), scalar(f.Value, 1)
if count == 0 || id != d.CostID || typ != d.CostType || qty == 0 || index == 0 || seen[index] || qty > math.MaxInt32 || total > math.MaxUint64-qty {
return fmt.Errorf("eventgames: invalid submitted cost")
}
seen[index] = true
total += qty
out = append(out, player.Item{InvenIndex: index, ID: id, Type: typ, Count: qty})
return nil
})
if e != nil {
return nil, e
}
if d.Cost > 0 && count > math.MaxUint64/d.Cost || total != count*d.Cost {
return nil, fmt.Errorf("eventgames: cost does not match design")
}
return out, nil
}
func stateWire(g GameState, d *gamedata.EventGame) []byte {
b := wire.AppendVarint(nil, 1, g.UID)
switch g.Type {
case 12:
b = wire.AppendVarint(b, 2, d.ScaffoldGroup)
b = wire.AppendVarint(b, 3, d.Cells[g.Position].ID)
b = wire.AppendVarint(b, 4, g.Clear)
case 13, 17:
b = wire.AppendVarint(b, 2, g.Clear)
for _, v := range g.Board {
b = wire.AppendVarint(b, 3, v)
}
for _, v := range g.Opened {
b = wire.AppendVarint(b, 4, v)
}
case 19:
b = wire.AppendVarint(b, 2, g.Free)
b = wire.AppendVarint(b, 3, g.Reset)
if g.Special {
b = wire.AppendVarint(b, 4, 1)
}
b = wire.AppendVarint(b, 5, g.Tries)
}
return b
}
func (s *Service) initialize(g *GameState, d *gamedata.EventGame) error {
if d.Type != 13 && d.Type != 17 {
return nil
}
count := g.Clear
if d.Type == 17 && count == 0 {
count = 1
g.Clear = 1
}
var cells []gamedata.EventGameReward
max := uint64(0)
for _, r := range d.Cells {
if r.Count > max {
max = r.Count
}
}
if count > max {
count = max
}
for _, r := range d.Cells {
if r.Count == count {
cells = append(cells, r)
}
}
if len(cells) == 0 {
return fmt.Errorf("eventgames: board rewards missing")
}
g.Board = nil
g.Opened = nil
g.Lines = nil
for _, r := range cells {
g.Board = append(g.Board, r.ID)
}
if d.Type == 13 {
for i := len(g.Board) - 1; i > 0; i-- {
j, e := s.sample(uint64(i + 1))
if e != nil {
return e
}
if j >= uint64(i+1) {
return fmt.Errorf("eventgames: sampler out of range")
}
g.Board[i], g.Board[j] = g.Board[j], g.Board[i]
}
}
return nil
}
+201
View File
@@ -0,0 +1,201 @@
package eventgames
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"fmt"
"testing"
"time"
)
type resolve struct{ kind uint64 }
func (r resolve) Resolve(id uint64) (events.Schedule, error) {
if id != 7 {
return events.Schedule{}, fmt.Errorf("unknown")
}
return events.Schedule{UID: 7, Type: r.kind, ID: 1, Start: 1, End: 9999999999999}, nil
}
type economy struct{ spent, granted uint64 }
func (e *economy) ConsumeAndGrant(_ string, c []player.Item, r []gamedata.BattleReward) ([]byte, error) {
for _, x := range c {
e.spent += x.Count
}
for _, x := range r {
e.granted += x.Count
}
return wire.AppendVarint(nil, 1, 1), nil
}
func fixture(t *testing.T, d *gamedata.EventGame) (*Service, *economy) {
t.Helper()
e := &economy{}
s, err := Open(stateio.NewMemory(), "", "", resolve{d.Type}, e)
if err != nil {
t.Fatal(err)
}
s.load = func(uint64, uint64) (*gamedata.EventGame, error) { return d, nil }
s.sample = func(n uint64) (uint64, error) {
if n == 0 {
return 0, fmt.Errorf("empty")
}
return 0, nil
}
s.now = func() time.Time { return time.UnixMilli(1000) }
return s, e
}
func request(seq, n, cost uint64) []byte {
b := wire.AppendVarint(nil, 1, seq)
b = wire.AppendVarint(b, 2, 7)
item := wire.AppendVarint(nil, 1, 1)
item = wire.AppendVarint(item, 2, 5)
item = wire.AppendVarint(item, 3, 8)
item = wire.AppendVarint(item, 4, cost)
return wire.AppendBytes(b, int(n), item)
}
func TestBoardCostStateAndWholeReplyRetry(t *testing.T) {
d := &gamedata.EventGame{ID: 1, Type: 12, Cost: 1, CostID: 5, CostType: 8, Cells: []gamedata.EventGameReward{{ID: 1}, {ID: 2, Rewards: []gamedata.BattleReward{{Type: 8, ID: 9, Count: 3}}}}, Moves: []struct{ ID, Min, Max uint64 }{{1, 1, 1}}}
s, e := fixture(t, d)
req := request(1, 3, 1)
_, a, _, err := s.HandleSession("/MiniGameBoardPlay", req, "sid")
if err != nil {
t.Fatal(err)
}
_, b, _, err := s.HandleSession("/MiniGameBoardPlay", req, "sid")
if err != nil || !bytes.Equal(a, b) || e.spent != 1 || e.granted != 3 {
t.Fatalf("retry error=%v spent=%d granted=%d", err, e.spent, e.granted)
}
bad := wire.AppendVarint(req, 4, 1)
if _, _, _, err = s.HandleSession("/MiniGameBoardPlay", bad, "sid"); err == nil {
t.Fatal("changed retry accepted")
}
if s.state.Games["7"].Position != 1 {
t.Fatal("move lost")
}
}
func TestPuzzleWordRenewAndNoDuplicateOpen(t *testing.T) {
d := &gamedata.EventGame{ID: 1, Type: 17, Cost: 1, CostID: 5, CostType: 8, Columns: 2, Cells: []gamedata.EventGameReward{{ID: 1, Count: 1, Slot: 12}, {ID: 2, Count: 1}, {ID: 1, Count: 2}}, Complete: []gamedata.EventGameReward{{ID: 1, Count: 1, Members: []uint64{1}, Rewards: []gamedata.BattleReward{{Type: 4, Count: 5}}}}}
s, e := fixture(t, d)
req := request(1, 4, 1)
req = wire.AppendVarint(req, 3, 1)
if _, _, _, err := s.HandleSession("/MiniPuzzleOpen", req, "sid"); err != nil {
t.Fatal(err)
}
if e.spent != 1 || e.granted != 5 {
t.Fatal("word award missing")
}
bad := request(2, 4, 1)
bad = wire.AppendVarint(bad, 3, 1)
if _, _, _, err := s.HandleSession("/MiniPuzzleOpen", bad, "sid"); err == nil {
t.Fatal("duplicate tile accepted")
}
renew := wire.AppendVarint(nil, 1, 3)
renew = wire.AppendVarint(renew, 2, 7)
if _, _, _, err := s.HandleSession("/MiniPuzzleRenew", renew, "sid"); err != nil {
t.Fatal(err)
}
if s.state.Games["7"].Clear != 2 || e.spent != 1 {
t.Fatal("renew cost/stage wrong")
}
}
func TestRouletteFreeCountAndMilestone(t *testing.T) {
d := &gamedata.EventGame{ID: 1, Type: 19, Cost: 1, CostID: 5, CostType: 8, Free: 1, Cells: []gamedata.EventGameReward{{ID: 1, Weight: 1, Rewards: []gamedata.BattleReward{{Count: 2}}}}, Complete: []gamedata.EventGameReward{{ID: 1, Count: 1, Rewards: []gamedata.BattleReward{{Count: 3}}}}}
s, e := fixture(t, d)
req := wire.AppendVarint(nil, 1, 1)
req = wire.AppendVarint(req, 2, 7)
req = wire.AppendVarint(req, 4, 1)
if _, _, _, err := s.HandleSession("/MiniGameRouletteDraw", req, "sid"); err != nil {
t.Fatal(err)
}
if e.spent != 0 || e.granted != 5 {
t.Fatal("free rewards wrong")
}
req, _, _ = wire.ReplaceVarint(req, 1, 2)
if _, _, _, err := s.HandleSession("/MiniGameRouletteDraw", req, "sid"); err == nil {
t.Fatal("extra free draw accepted")
}
}
func TestBingoLinesUseProtocolEnums(t *testing.T) {
g := GameState{Board: make([]uint64, 9), Opened: []uint64{0, 4, 8}}
if !completeLine(&g, 3, 0, 0) || completeLine(&g, 3, 1, 0) {
t.Fatal("diagonal/row enum wrong")
}
g.Opened = []uint64{0, 1, 2}
if !completeLine(&g, 3, 1, 0) {
t.Fatal("row wrong")
}
g.Opened = []uint64{0, 3, 6}
if !completeLine(&g, 3, 2, 0) {
t.Fatal("column wrong")
}
}
func TestBingoLineRewardExactlyOnceAndBeforeAfterWire(t *testing.T) {
d := &gamedata.EventGame{ID: 1, Type: 13, Columns: 2, Cells: []gamedata.EventGameReward{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}, Lines: []gamedata.EventGameReward{{Count: 0, LineType: 1, LineIndex: 0, Rewards: []gamedata.BattleReward{{Count: 7}}}}}
s, _ := fixture(t, d)
g := GameState{UID: 7, Type: 13, ID: 1, Board: []uint64{1, 2, 3, 4}, Opened: []uint64{0}}
body, _, rs, err := s.play("/MiniGameBingoPlay", wire.AppendVarint(nil, 4, 1), &g, d)
if err != nil || len(rs[6]) != 1 {
t.Fatalf("line reward %v %+v", err, rs)
}
line, found, e := wire.Bytes(body, 4)
if e != nil || !found || scalar(line, 1) != 1 || scalar(line, 2) != 0 {
t.Fatalf("line wire %x", body)
}
_, _, rs, err = s.play("/MiniGameBingoPlay", wire.AppendVarint(nil, 4, 1), &g, d)
if err != nil || len(rs[6]) != 0 {
t.Fatal("same line granted twice")
}
}
func TestMiniGameSamplerOutOfRangeRejected(t *testing.T) {
d := &gamedata.EventGame{ID: 1, Type: 13, Columns: 2, Cells: []gamedata.EventGameReward{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}}
s, _ := fixture(t, d)
s.sample = func(n uint64) (uint64, error) { return n, nil }
g := GameState{}
if err := s.initialize(&g, d); err == nil {
t.Fatal("shuffle accepted bad RNG")
}
g.Board = []uint64{1, 2, 3, 4}
if _, _, _, err := s.play("/MiniGameBingoPlay", wire.AppendVarint(nil, 4, 1), &g, d); err == nil {
t.Fatal("draw accepted bad RNG")
}
}
func TestSavedMiniGameBoardAndCompletedLineRejected(t *testing.T) {
d := &gamedata.EventGame{ID: 1, Type: 13, Columns: 2, Cells: []gamedata.EventGameReward{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}, Lines: []gamedata.EventGameReward{{LineType: 1, LineIndex: 0}}}
g := GameState{UID: 7, Type: 13, ID: 1, Board: []uint64{1, 2, 3}}
if e := validateSaved("7", g, d); e == nil {
t.Fatal("truncated board accepted")
}
g.Board = append(g.Board, 4)
g.Lines = []uint64{1000}
if e := validateSaved("7", g, d); e == nil {
t.Fatal("uncompleted line claimed")
}
g.Opened = []uint64{0, 1}
if e := validateSaved("7", g, d); e != nil {
t.Fatal(e)
}
}
func TestRoulettePityAndAccumulatedWire(t *testing.T) {
d := &gamedata.EventGame{ID: 1, Type: 19, RewardGroup: 77, Pity: 3, Cells: []gamedata.EventGameReward{{ID: 1, Weight: 100, Weight2: 100}, {ID: 2, Slot: 1, Weight: 1, Weight2: 1}}, Complete: []gamedata.EventGameReward{{Count: 3, Rewards: []gamedata.BattleReward{{Count: 5}}}}}
s, _ := fixture(t, d)
g := GameState{UID: 7, Type: 19, ID: 1, Tries: 2, SinceSpecial: 2}
req := wire.AppendVarint(wire.AppendVarint(nil, 3, 1), 4, 1)
body, _, rewards, e := s.play("/MiniGameRouletteDraw", req, &g, d)
if e != nil || !g.Special || g.SinceSpecial != 0 || g.Tries != 3 || len(rewards[3]) != 1 {
t.Fatalf("pity state %+v rewards%v err%v", g, rewards, e)
}
info, found, _ := wire.Bytes(body, 4)
group, _, _ := wire.Varint(info, 1)
id, _, _ := wire.Varint(info, 2)
if !found || group != 77 || id != 2 {
t.Fatalf("roulette field4 %x", body)
}
}
+294
View File
@@ -0,0 +1,294 @@
package eventgames
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"fmt"
"math"
)
func (s *Service) play(path string, req []byte, g *GameState, d *gamedata.EventGame) ([]byte, uint64, map[int][]gamedata.BattleReward, error) {
var body []byte
cost := uint64(1)
rewards := map[int][]gamedata.BattleReward{}
fail := func(e error) ([]byte, uint64, map[int][]gamedata.BattleReward, error) { return nil, 0, nil, e }
switch d.Type {
case 12:
if len(d.Moves) == 0 {
return fail(fmt.Errorf("eventgames: move rules missing"))
}
for _, m := range d.Moves {
if m.Max < m.Min {
return fail(fmt.Errorf("eventgames: invalid move range"))
}
x, e := s.sample(m.Max - m.Min + 1)
if e != nil {
return fail(e)
}
if x >= m.Max-m.Min+1 {
return fail(fmt.Errorf("eventgames: sampler out of range"))
}
steps := m.Min + x
v := wire.AppendVarint(nil, 1, m.ID)
v = wire.AppendVarint(v, 2, steps)
body = wire.AppendBytes(body, 2, v)
old := g.Clear
g.Clear += (g.Position + steps) / uint64(len(d.Cells))
g.Position = (g.Position + steps) % uint64(len(d.Cells))
for _, r := range d.Complete {
if r.Count > old && r.Count <= g.Clear {
rewards[3] = append(rewards[3], r.Rewards...)
}
}
}
rewards[4] = append(rewards[4], d.Cells[g.Position].Rewards...)
case 13:
plays := scalar(req, 4)
if plays == 0 || plays > uint64(len(g.Board)-len(g.Opened)) {
return fail(fmt.Errorf("eventgames: invalid bingo play count"))
}
cost = plays
for i := uint64(0); i < plays; i++ {
var available []uint64
for pos := range g.Board {
if !contains(g.Opened, uint64(pos)) {
available = append(available, uint64(pos))
}
}
pick, e := s.sample(uint64(len(available)))
if e != nil {
return fail(e)
}
if pick >= uint64(len(available)) {
return fail(fmt.Errorf("eventgames: sampler out of range"))
}
pos := available[pick]
g.Opened = append(g.Opened, pos)
body = wire.AppendVarint(body, 3, pos)
for _, r := range d.Cells {
if r.ID == g.Board[pos] && r.Count == rewardCount(d, g.Clear) {
rewards[5] = append(rewards[5], r.Rewards...)
}
}
}
for _, r := range d.Lines {
if r.Count != rewardCount(d, g.Clear) {
continue
}
key := r.LineType*1000 + r.LineIndex
if contains(g.Lines, key) {
continue
}
if completeLine(g, d.Columns, r.LineType, r.LineIndex) {
g.Lines = append(g.Lines, key)
v := wire.AppendVarint(nil, 1, r.LineType)
v = wire.AppendVarint(v, 2, r.LineIndex)
body = wire.AppendBytes(body, 4, v)
rewards[6] = append(rewards[6], r.Rewards...)
}
}
if len(g.Opened) == len(g.Board) {
g.Clear++
for _, r := range d.Complete {
if r.Count == g.Clear {
rewards[7] = append(rewards[7], r.Rewards...)
}
}
if e := s.initialize(g, d); e != nil {
return fail(e)
}
}
case 17:
if path == "/MiniPuzzleRenew" {
wordReady := false
for _, r := range d.Complete {
if r.Count != g.Clear {
continue
}
wordReady = true
for _, id := range r.Members {
if !contains(g.Opened, id) {
return fail(fmt.Errorf("eventgames: puzzle word incomplete"))
}
}
}
if !wordReady {
return fail(fmt.Errorf("eventgames: puzzle word rules missing"))
}
max := uint64(0)
for _, r := range d.Cells {
if r.Count > max {
max = r.Count
}
}
if g.Clear >= max {
return fail(fmt.Errorf("eventgames: final puzzle completed"))
}
g.Clear++
if e := s.initialize(g, d); e != nil {
return fail(e)
}
return body, 0, rewards, nil
}
var opens []uint64
if path == "/MiniPuzzleAllOpen" {
for _, id := range g.Board {
if !contains(g.Opened, id) {
opens = append(opens, id)
}
}
} else {
id := scalar(req, 3)
if !contains(g.Board, id) || contains(g.Opened, id) {
return fail(fmt.Errorf("eventgames: puzzle tile unavailable"))
}
opens = []uint64{id}
}
if len(opens) == 0 {
return fail(fmt.Errorf("eventgames: puzzle already open"))
}
cost = uint64(len(opens))
for _, id := range opens {
g.Opened = append(g.Opened, id)
for _, r := range d.Cells {
if r.ID == id && r.Count == g.Clear {
rewards[4] = append(rewards[4], r.Rewards...)
if r.Slot != 0 {
v := wire.AppendVarint(nil, 1, id)
v = wire.AppendVarint(v, 2, r.Slot)
body = wire.AppendBytes(body, 3, v)
}
}
}
}
for _, r := range d.Complete {
if r.Count != g.Clear || contains(g.Lines, r.ID) {
continue
}
all := true
for _, id := range r.Members {
all = all && contains(g.Opened, id)
}
if all {
g.Lines = append(g.Lines, r.ID)
rewards[5] = append(rewards[5], r.Rewards...)
}
}
case 19:
n := scalar(req, 4)
typ := scalar(req, 3)
if n == 0 || n > 100 || typ > 1 {
return fail(fmt.Errorf("eventgames: invalid roulette draw"))
}
if typ == 0 {
if n > g.Free {
return fail(fmt.Errorf("eventgames: insufficient free draws"))
}
g.Free -= n
cost = 0
} else {
cost = n
if d.MaxConsume > 0 && n > d.MaxConsume {
return fail(fmt.Errorf("eventgames: roulette draw exceeds limit"))
}
}
for i := uint64(0); i < n; i++ {
force := d.Pity > 0 && g.SinceSpecial+1 >= d.Pity
var candidates []gamedata.EventGameReward
total := uint64(0)
for _, r := range d.Cells {
if force && r.Slot != 1 {
continue
}
w := r.Weight
if g.Special {
w = r.Weight2
}
if w == 0 {
continue
}
if total > math.MaxUint64-w {
return fail(fmt.Errorf("eventgames: roulette weights overflow"))
}
total += w
candidates = append(candidates, r)
}
if total == 0 {
return fail(fmt.Errorf("eventgames: roulette eligible pool empty"))
}
x, e := s.sample(total)
if e != nil {
return fail(e)
}
if x >= total {
return fail(fmt.Errorf("eventgames: sampler out of range"))
}
var chosen gamedata.EventGameReward
for _, r := range candidates {
w := r.Weight
if g.Special {
w = r.Weight2
}
if x < w {
chosen = r
break
}
x -= w
}
g.Tries++
g.SinceSpecial++
if chosen.Slot == 1 {
g.Special = true
g.SinceSpecial = 0
}
rewards[2] = append(rewards[2], chosen.Rewards...)
v := wire.AppendVarint(nil, 1, d.RewardGroup)
v = wire.AppendVarint(v, 2, chosen.ID)
body = wire.AppendBytes(body, 4, v)
for _, r := range d.Complete {
if r.Count == g.Tries {
rewards[3] = append(rewards[3], r.Rewards...)
}
}
}
}
return body, cost, rewards, nil
}
func rewardCount(d *gamedata.EventGame, count uint64) uint64 {
max := uint64(0)
for _, r := range d.Cells {
if r.Count > max {
max = r.Count
}
}
if count > max {
return max
}
return count
}
func completeLine(g *GameState, n, typ, index uint64) bool {
if n == 0 {
return false
}
for i := uint64(0); i < n; i++ {
var pos uint64
switch typ {
case 1:
pos = index*n + i
case 2:
pos = i*n + index
case 0:
if index == 0 {
pos = i*n + i
} else {
pos = i*n + (n - 1 - i)
}
default:
return false
}
if pos >= uint64(len(g.Board)) || !contains(g.Opened, pos) {
return false
}
}
return true
}
@@ -0,0 +1,87 @@
package eventgames
import (
"bd2server/internal/server/gamedata"
"fmt"
"strconv"
)
func validateSaved(key string, g GameState, d *gamedata.EventGame) error {
if key != strconv.FormatUint(g.UID, 10) || g.ID != d.ID || g.Type != d.Type {
return fmt.Errorf("eventgames: invalid saved identity")
}
if g.Type == 12 {
if g.Position >= uint64(len(d.Cells)) {
return fmt.Errorf("eventgames: invalid saved position")
}
return nil
}
if g.Type == 19 {
if g.Free > d.Free || g.SinceSpecial > g.Tries {
return fmt.Errorf("eventgames: invalid saved roulette counters")
}
return nil
}
expected := 0
for _, r := range d.Cells {
if r.Count == rewardCount(d, g.Clear) {
expected++
}
}
if len(g.Board) != expected || expected == 0 {
return fmt.Errorf("eventgames: saved board size differs from design")
}
if g.Type == 13 && (d.Columns == 0 || uint64(expected) != d.Columns*d.Columns) {
return fmt.Errorf("eventgames: invalid bingo board dimensions")
}
seen := map[uint64]bool{}
for _, id := range g.Board {
if seen[id] {
return fmt.Errorf("eventgames: duplicate saved board tile")
}
seen[id] = true
found := false
for _, r := range d.Cells {
if r.ID == id && r.Count == rewardCount(d, g.Clear) {
found = true
break
}
}
if !found {
return fmt.Errorf("eventgames: saved tile outside design")
}
}
seen = map[uint64]bool{}
for _, id := range g.Opened {
if seen[id] || g.Type == 13 && id >= uint64(len(g.Board)) || g.Type == 17 && !contains(g.Board, id) {
return fmt.Errorf("eventgames: invalid saved open tile")
}
seen[id] = true
}
lineSeen := map[uint64]bool{}
for _, key := range g.Lines {
if lineSeen[key] {
return fmt.Errorf("eventgames: duplicate saved line")
}
lineSeen[key] = true
found := false
for _, r := range d.Lines {
if g.Type == 13 && r.Count == rewardCount(d, g.Clear) && key == r.LineType*1000+r.LineIndex && completeLine(&g, d.Columns, r.LineType, r.LineIndex) {
found = true
}
}
for _, r := range d.Complete {
if g.Type == 17 && r.Count == g.Clear && r.ID == key {
all := true
for _, id := range r.Members {
all = all && contains(g.Opened, id)
}
found = all
}
}
if !found {
return fmt.Errorf("eventgames: saved completed line invalid")
}
}
return nil
}
+40
View File
@@ -0,0 +1,40 @@
# Event play runtime policy
Registry dates and UIDs are provided by server configuration. No calendar is
invented or enabled here. Every mutable operation is saved with its session,
path, sequence and exact request, and retries replay the complete response.
Production runs the economy and snapshot writes in one repository transaction.
Event stories use installed PackEventStoryTable rows. Seasonal clears and
archive replay clears have separate once-only reward ledgers. Event battles
use mode 17 and the client's custom stage fields 8/9, not monster field 3.
Installed PackEventBattleTable supplies the matching deck, win cost and repeat
rewards. Previous stages must be cleared. Failed/abandoned battle closes the
run without victory cost or rewards. Only server-proven stage clears are
returned by PackEventBattleInfo. BattleEnd reward bundle is field 5 and event
stage info is field 16.
Client-simulated minigames own a persistent run per session and family. Start
locks schedule and stage; End requires that run and commits its record before
the run closes. Rankings project local records, not official global scores.
Run/Field use FieldMiniGameRewardTable score thresholds; higher daily records
pay per-resource positive differences. Quick reward is only available for a
stored best score not already paid for that UTC day.
Field scores are computed from installed pack FieldMiniGameObjectTable IDs;
objects may be scored only once per run. Rhythm stage and mode select the
installed maximum score. Hopscotch captured area is range checked. Survival
start validates character/map and supplies the installed starting skill;
play coin/experience is recorded and End cannot exceed committed progress.
Upgrade costs come from FieldMiniGameUpgradeTable and currency 43. Skill
offers come from installed FieldMiniGameSkillGroupTable IDs. Defense stages
pay installed MGDRewardTable wave rewards once per saved record.
EventHub settings are projected from static PackEventListTable and configured
child schedules. Mini hub slots use their distinct protobuf shape. A registry
with no configured events yields empty Info responses rather than activating
unconfigured content.
This package intentionally does not turn a plain RandomBox reward into an
immediate opening. Reward type 9 remains inventory; manual box opening belongs
to the shared economy's box-opening operation.
+175
View File
@@ -0,0 +1,175 @@
package eventplay
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"bytes"
"encoding/json"
"fmt"
"strings"
)
func (s *Service) HandlesBattle(mode uint64) bool { return mode == 17 }
func (s *Service) EnterBattle(req []byte, receipt string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
if num(req, 5) != 17 || receipt == "" {
return nil, fmt.Errorf("eventplay: invalid event battle")
}
group, id, deck := num(req, 8), num(req, 9), num(req, 4)
var row []byte
for _, r := range s.design.Rows("PackEventBattleTable", 4, group) {
if num(r, 5) == id && num(r, 1) == deck {
row = r
break
}
}
if row == nil {
return nil, fmt.Errorf("eventplay: event battle deck/stage mismatch")
}
var uid uint64
for _, c := range s.registry.List() {
if c.ID == group && (c.Type == 9 || c.Type == 8) && s.now().UnixMilli() >= c.Start && s.now().UnixMilli() < c.End {
uid = c.UID
break
}
}
if uid == 0 {
return nil, fmt.Errorf("eventplay: event battle schedule unavailable")
}
previous := uint64(0)
for _, r := range s.design.Rows("PackEventBattleTable", 4, group) {
stage := num(r, 5)
if stage < id && stage > previous {
previous = stage
}
}
if previous != 0 && !s.state.Stories[fmt.Sprintf("battle:%d:%d:%d", uid, group, previous)] {
return nil, fmt.Errorf("eventplay: previous event battle not cleared")
}
if validator, ok := s.economy.(interface{ CanApply([]gamedata.Reward) error }); ok {
cost := num(row, 3)
if cost > 0 {
if e := validator.CanApply([]gamedata.Reward{{Type: 30, Count: cost}}); e != nil {
return nil, e
}
}
}
payload, _ := json.Marshal(s.state)
var next snapshot
_ = json.Unmarshal(payload, &next)
next.Runs["battle:"+receipt] = Run{UID: uid, Game: group, Stage: id, Mode: 17, Started: uint64(s.now().UnixMilli()), Family: "Battle", Session: strings.Split(receipt, ":")[0]}
payload, _ = json.Marshal(next)
if e := s.store.Save("eventplay", payload); e != nil {
return nil, e
}
s.state = next
return nil, nil
}
func (s *Service) CompleteBattle(req []byte, receipt string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
key := "eventbattle:" + receipt
if r, ok := s.state.Replies[key]; ok {
if !bytes.Equal(r.Request, req) {
return nil, fmt.Errorf("eventplay: changed battle retry")
}
return r.Body, nil
}
a, ok := s.state.Runs["battle:"+receipt]
if !ok {
return nil, fmt.Errorf("eventplay: no entered event battle")
}
var row []byte
for _, r := range s.design.Rows("PackEventBattleTable", 4, a.Game) {
if num(r, 5) == a.Stage {
row = r
break
}
}
if row == nil {
return nil, fmt.Errorf("eventplay: saved battle design missing")
}
raw, _ := json.Marshal(s.state)
var next snapshot
_ = json.Unmarshal(raw, &next)
var out []byte
if num(req, 2) == 1 {
rs, e := gamedata.EventPlayRewards(row, 9, 10, 8)
if e != nil {
return nil, e
}
definitions := s.battleChallenges[num(row, 1)]
claims, e := gamedata.VerifySubmittedChallenges(req, definitions)
if e != nil {
return nil, e
}
for _, index := range claims {
marker := fmt.Sprintf("challenge:%d:%d:%d:%d", a.UID, a.Game, a.Stage, index)
if !next.Stories[marker] {
rs = append(rs, definitions[index].Reward)
next.Stories[marker] = true
}
}
rewards := []gamedata.Reward{}
for _, r := range rs {
rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count})
}
cost := num(row, 3)
var costs []gamedata.Reward
if cost > 0 {
costs = []gamedata.Reward{{Type: 30, Count: cost}}
}
bundle, e := s.economy.Apply(key, costs, rewards)
if e != nil {
return nil, e
}
out = wire.AppendBytes(out, 5, bundle)
info := wire.AppendVarint(nil, 1, a.UID)
info = wire.AppendVarint(info, 2, a.Game)
info = wire.AppendVarint(info, 3, a.Stage)
for i := range definitions {
if next.Stories[fmt.Sprintf("challenge:%d:%d:%d:%d", a.UID, a.Game, a.Stage, i)] {
info = wire.AppendVarint(info, 4, uint64(i))
}
}
out = wire.AppendBytes(out, 16, info)
next.Stories[fmt.Sprintf("battle:%d:%d:%d", a.UID, a.Game, a.Stage)] = true
}
delete(next.Runs, "battle:"+receipt)
next.Replies[key] = reply{append([]byte(nil), req...), out}
raw, _ = json.Marshal(next)
if e := s.store.Save("eventplay", raw); e != nil {
return nil, e
}
s.state = next
return out, nil
}
func (s *Service) AttachBattleChallenges(d gamedata.EventBattleChallenges) {
s.mu.Lock()
defer s.mu.Unlock()
s.battleChallenges = d
}
func (s *Service) BattleChallengeIndexes(uid, group, stage uint64) []uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.battleChallengeIndexesLocked(uid, group, stage)
}
func (s *Service) battleChallengeIndexesLocked(uid, group, stage uint64) []uint64 {
var deck uint64
for _, row := range s.design.Rows("PackEventBattleTable", 4, group) {
if num(row, 5) == stage {
deck = num(row, 1)
break
}
}
var out []uint64
for i := range s.battleChallenges[deck] {
if s.state.Stories[fmt.Sprintf("challenge:%d:%d:%d:%d", uid, group, stage, i)] {
out = append(out, uint64(i))
}
}
return out
}
@@ -0,0 +1,53 @@
package eventplay
import (
"bytes"
"testing"
"bd2server/internal/server/readonly"
"bd2server/internal/server/wire"
)
func TestProjectHubCalendarRetainsPlayEndAndMultipleUIDs(t *testing.T) {
s, _ := makeService(t)
scalar := func(n int, v uint64) readonly.Field {
return readonly.Field{Number: n, Type: 0, Varint: v}
}
hub := readonly.Field{Number: 1, Type: 2, Fields: []readonly.Field{
scalar(1, 74), scalar(2, 57), scalar(3, 100), scalar(4, 200), scalar(5, 300),
{Number: 6, Type: 2, Fields: []readonly.Field{
scalar(1, 1), scalar(2, 3), scalar(3, 77), scalar(3, 78),
}},
}}
seed := &readonly.Seed{Responses: map[string]readonly.Response{
"/EventHubInfo": {Fields: []readonly.Field{hub}},
"/MiniGameHubInfo": {PacketCode: 390, Fields: []readonly.Field{
{Number: 1, Type: 2, Fields: []readonly.Field{scalar(1, 4), scalar(2, 9), scalar(3, 0)}},
}},
}}
s.AttachHubCalendars(seed)
for _, path := range []string{"/EventHubInfo", "/MiniGameHubInfo"} {
req := wire.AppendVarint(nil, 1, 1)
wantCode, want, _, err := seed.Handle(path, req)
if err != nil {
t.Fatal(err)
}
code, got, handled, err := s.HandleSession(path, req, "session")
if err != nil || !handled || code != wantCode || !bytes.Equal(got, want) {
t.Fatalf("path=%s code=%d handled=%v err=%v body=%x want=%x", path, code, handled, err, got, want)
}
if path == "/EventHubInfo" {
raw, _, _ := wire.Bytes(got, 1)
playEnd, _, _ := wire.Varint(raw, 4)
end, _, _ := wire.Varint(raw, 5)
setting, _, _ := wire.Bytes(raw, 6)
ids, err := list(setting, 3)
if err != nil || playEnd != 200 || end != 300 || len(ids) != 2 || ids[0] != 77 || ids[1] != 78 {
t.Fatalf("hub play_end=%d end=%d refs=%v err=%v", playEnd, end, ids, err)
}
}
}
if len(s.state.Replies) != 0 {
t.Fatal("public calendars persisted as player replies")
}
}
+775
View File
@@ -0,0 +1,775 @@
// Package eventplay owns event stories and client-simulated minigame runs.
package eventplay
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/readonly"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"sync"
"time"
)
type Rewards interface {
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
}
type Run struct {
UID, Game, Stage, Mode, Started, Score, HP uint64
Family, Session string
Objects, Skills []uint64
Coin, Exp, Char, MapGroup uint64
SkillLevels map[uint64]uint64
MaxHP, Rerolls, Level, SkillCredits uint64
Offers []uint64
LevelExp uint64
Killed, Picked, Available map[uint64]uint64
}
type record struct {
UID, Game, Stage, Mode, Best, Paid, Date uint64
Family string
Clears map[string]bool
PendingBundle []byte
}
type reply struct{ Request, Body []byte }
type snapshot struct {
Version int `json:"version"`
Records map[string]record `json:"records"`
Runs map[string]Run `json:"runs"`
Replies map[string]reply `json:"replies"`
Stories map[string]bool `json:"stories"`
Upgrades map[string]uint64 `json:"upgrades"`
}
type Service struct {
battleChallenges gamedata.EventBattleChallenges
mu sync.Mutex
store stateio.Store
registry events.Resolver
economy Rewards
design *gamedata.EventPlayCatalog
state snapshot
now func() time.Time
rooms RoomRuntime
onProgress func(uint64, uint64, uint64) error
hubCalendars *readonly.Seed
}
func Open(store stateio.Store, root, version string, registry events.Resolver, economy Rewards) (*Service, error) {
if store == nil || registry == nil || economy == nil {
return nil, fmt.Errorf("eventplay: incomplete configuration")
}
d, e := gamedata.LoadEventPlayCatalog(root, version)
if e != nil {
return nil, e
}
s := &Service{store: store, registry: registry, economy: economy, design: d, now: time.Now, state: snapshot{1, map[string]record{}, map[string]Run{}, map[string]reply{}, map[string]bool{}, map[string]uint64{}}}
raw, e := store.Load("eventplay")
if e != nil {
return nil, e
}
if raw != nil {
if e = stateio.RequireExactJSONObject(raw, "version", "records", "runs", "replies", "stories", "upgrades"); e != nil {
return nil, e
}
if e = json.Unmarshal(raw, &s.state); e != nil {
return nil, e
}
if s.state.Version != 1 || s.state.Records == nil || s.state.Runs == nil || s.state.Replies == nil || s.state.Stories == nil || s.state.Upgrades == nil {
return nil, fmt.Errorf("eventplay: incompatible save")
}
}
for _, r := range s.state.Records {
if r.UID == 0 || r.Game == 0 || r.Best > math.MaxInt32 || r.Clears == nil {
return nil, fmt.Errorf("eventplay: invalid saved record")
}
if _, e = s.design.Row("PackEventMiniGameTable", 8, r.Game); e != nil {
return nil, e
}
}
return s, nil
}
func num(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v }
// AttachHubCalendars installs project-maintained hub layouts, including the
// separate play/end windows and slots referencing multiple domain identities.
func (s *Service) AttachHubCalendars(seed *readonly.Seed) {
s.hubCalendars = seed
}
func list(b []byte, n int) ([]uint64, error) {
var out []uint64
e := wire.Walk(b, func(f wire.Field) error {
if f.Number != n {
return nil
}
if f.Type == 0 {
v, _ := binary.Uvarint(f.Value)
out = append(out, v)
return nil
}
if f.Type != 2 {
return wire.ErrMalformed
}
for p := f.Value; len(p) > 0; {
v, k := binary.Uvarint(p)
if k <= 0 {
return wire.ErrMalformed
}
out = append(out, v)
p = p[k:]
}
return nil
})
return out, e
}
func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) {
return s.HandleSession(path, req, "local")
}
func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) {
code, ok := codes[path]
if !ok {
return 0, nil, false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
fail := func(e error) (int, []byte, bool, error) { return code, nil, true, e }
seq := num(req, 1)
if seq == 0 || seq > math.MaxInt32 {
return fail(fmt.Errorf("eventplay: invalid sequence"))
}
if e := wire.Walk(req, func(f wire.Field) error {
if f.Type == 0 {
v, _ := binary.Uvarint(f.Value)
if v > math.MaxInt64 {
return wire.ErrMalformed
}
}
return nil
}); e != nil {
return fail(e)
}
key := fmt.Sprintf("%s:%s:%d", session, path, seq)
if r, ok := s.state.Replies[key]; ok {
if !bytes.Equal(r.Request, req) {
return fail(fmt.Errorf("eventplay: changed request retry"))
}
return code, r.Body, true, nil
}
raw, _ := json.Marshal(s.state)
var next snapshot
_ = json.Unmarshal(raw, &next)
var out []byte
var e error
switch path {
case "/MiniGameUserRecordInfo", "/MiniGameRanking":
out = summaryWire(&next, path == "/MiniGameUserRecordInfo")
case "/MiniGameRelayServerInfo":
return fail(fmt.Errorf("eventplay: native relay channels are not configured"))
case "/EventHubInfo", "/MiniEventHubInfo", "/MiniGameHubInfo":
if s.hubCalendars != nil && path != "/MiniEventHubInfo" {
return s.hubCalendars.Handle(path, req)
}
out, e = s.hubs(path)
case "/PackEventStoryInfo", "/PackEventBattleInfo":
uids, err := list(req, 2)
if err != nil {
return fail(err)
}
for _, uid := range uids {
c, err := s.registry.Resolve(uid)
if err != nil {
return fail(err)
}
if path == "/PackEventStoryInfo" {
for _, r := range s.design.Rows("PackEventStoryTable", 1, c.ID) {
id := num(r, 2)
if next.Stories[fmt.Sprintf("%d:%d:%d", uid, c.ID, id)] {
v := wire.AppendVarint(nil, 1, uid)
v = wire.AppendVarint(v, 2, c.ID)
v = wire.AppendVarint(v, 3, id)
out = wire.AppendBytes(out, 1, v)
}
}
} else {
for _, r := range s.design.Rows("PackEventBattleTable", 4, c.ID) {
id := num(r, 5)
if !next.Stories[fmt.Sprintf("battle:%d:%d:%d", uid, c.ID, id)] {
continue
}
v := wire.AppendVarint(nil, 1, uid)
v = wire.AppendVarint(v, 2, c.ID)
v = wire.AppendVarint(v, 3, id)
for _, idx := range s.battleChallengeIndexesLocked(uid, c.ID, id) {
v = wire.AppendVarint(v, 4, idx)
}
out = wire.AppendBytes(out, 1, v)
}
}
}
case "/PackEventStoryClear", "/PackEventStoryReplayClear":
uid, group, id := num(req, 2), num(req, 3), num(req, 4)
if path == "/PackEventStoryReplayClear" {
group, id = num(req, 2), num(req, 3)
uid = 0
}
if uid != 0 {
c, err := s.registry.Resolve(uid)
if err != nil {
return fail(err)
}
if c.ID != group || s.now().UnixMilli() < c.Start || s.now().UnixMilli() >= c.End {
return fail(fmt.Errorf("eventplay: story schedule unavailable"))
}
}
r, err := s.design.Story(group, id)
if err != nil {
return fail(err)
}
storyKey := fmt.Sprintf("%d:%d:%d", uid, group, id)
if !next.Stories[storyKey] {
rs, err := gamedata.EventPlayRewards(r, 11, 10, 9)
if err != nil {
return fail(err)
}
out, e = s.grant("eventplay:story:"+storyKey, rs)
if e != nil {
return fail(e)
}
out = wire.AppendBytes(nil, 1, out)
next.Stories[storyKey] = true
}
if path == "/PackEventStoryClear" {
v := wire.AppendVarint(nil, 1, uid)
v = wire.AppendVarint(v, 2, group)
v = wire.AppendVarint(v, 3, id)
out = wire.AppendBytes(out, 2, v)
}
default:
out, e = s.game(path, req, session, &next, key)
}
if e != nil {
return fail(e)
}
next.Replies[key] = reply{append([]byte(nil), req...), out}
b, e := json.Marshal(next)
if e != nil {
return fail(e)
}
if e = s.store.Save("eventplay", b); e != nil {
return fail(e)
}
s.state = next
return code, out, true, nil
}
func (s *Service) grant(identity string, rewards []gamedata.BattleReward) ([]byte, error) {
rs := make([]gamedata.Reward, 0, len(rewards))
for _, r := range rewards {
rs = append(rs, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count})
}
return s.economy.Apply(identity, nil, rs)
}
func (s *Service) hubs(path string) ([]byte, error) {
var out []byte
rows := s.registry.List()
sort.Slice(rows, func(i, j int) bool { return rows[i].UID < rows[j].UID })
for _, c := range rows {
if c.Type != 8 && c.Type != 11 {
continue
}
if path == "/MiniGameHubInfo" {
if c.Type != 11 {
continue
}
v := wire.AppendVarint(nil, 1, c.SubID)
v = wire.AppendVarint(v, 2, c.UID)
v = wire.AppendVarint(v, 3, 1)
out = wire.AppendBytes(out, 1, v)
continue
}
if path == "/MiniEventHubInfo" {
v := wire.AppendVarint(nil, 1, c.UID)
v = wire.AppendVarint(v, 2, c.ID)
v = wire.AppendVarint(v, 3, uint64(c.Start))
v = wire.AppendVarint(v, 4, uint64(c.End))
v = wire.AppendVarint(v, 5, uint64(c.End))
for _, child := range rows {
if child.UID == c.UID || child.SubID != c.ID {
continue
}
slot := wire.AppendVarint(nil, 1, child.SubID)
slot = wire.AppendVarint(slot, 2, child.Type)
slot = wire.AppendVarint(slot, 3, child.ID)
slot = wire.AppendVarint(slot, 4, child.UID)
slot = wire.AppendVarint(slot, 5, uint64(child.Start))
slot = wire.AppendVarint(slot, 6, uint64(child.End))
v = wire.AppendBytes(v, 6, slot)
}
out = wire.AppendBytes(out, 1, v)
continue
}
hub, e := s.design.Row("PackEventHubTable", 14, c.ID)
if e != nil {
return nil, e
}
_ = hub
v := wire.AppendVarint(nil, 1, c.UID)
v = wire.AppendVarint(v, 2, c.ID)
v = wire.AppendVarint(v, 3, uint64(c.Start))
v = wire.AppendVarint(v, 4, uint64(c.End))
v = wire.AppendVarint(v, 5, uint64(c.End))
for _, r := range s.design.Rows("PackEventListTable", 6, c.ID) {
typ, id, slot := num(r, 9), num(r, 7), num(r, 11)
for _, child := range rows {
if child.ID != id {
continue
}
setting := wire.AppendVarint(nil, 1, slot)
setting = wire.AppendVarint(setting, 2, typ)
setting = wire.AppendVarint(setting, 3, child.UID)
v = wire.AppendBytes(v, 6, setting)
break
}
}
out = wire.AppendBytes(out, 1, v)
}
return out, nil
}
func recordWire(r record) []byte {
b := wire.AppendVarint(nil, 1, r.UID)
b = wire.AppendVarint(b, 2, r.Paid)
b = wire.AppendVarint(b, 3, r.Best)
if r.Best > 0 && r.Paid < r.Best {
b = wire.AppendVarint(b, 4, 1)
}
return b
}
func family(path string) string {
p := strings.TrimPrefix(path, "/MiniGame")
for _, f := range []string{"Hopscotch", "Survival", "Defense", "Rhythm", "Sichuan", "Action", "Field", "Run"} {
if strings.HasPrefix(p, f) {
return f
}
}
return ""
}
func recKey(uid, stage, mode uint64, f string) string {
return fmt.Sprintf("%d:%s:%d:%d", uid, f, stage, mode)
}
func (s *Service) calendar(uid uint64, f string) (events.Schedule, error) {
if uid == 0 {
for _, c := range s.registry.List() {
if c.Type == 11 {
d, e := s.design.Row("PackEventMiniGameTable", 8, c.ID)
if e != nil {
continue
}
kind := num(d, 3)
expected := map[string]uint64{"Field": 1, "Run": 3, "Survival": 5, "Sichuan": 6, "Defense": 7, "Action": 8, "Rhythm": 9, "Hopscotch": 12}[f]
if f == "Run" && (kind == 2 || kind == 4) {
expected = kind
}
if kind != expected {
continue
}
uid = c.UID
break
}
}
}
c, e := s.registry.Resolve(uid)
if e != nil {
return c, e
}
if c.Type != 11 {
return c, fmt.Errorf("eventplay: minigame schedule mismatch")
}
if _, e = s.design.Row("PackEventMiniGameTable", 8, c.ID); e != nil {
return c, e
}
d, _ := s.design.Row("PackEventMiniGameTable", 8, c.ID)
kind := num(d, 3)
expected := map[string]uint64{"Field": 1, "Run": 3, "Survival": 5, "Sichuan": 6, "Defense": 7, "Action": 8, "Rhythm": 9, "Hopscotch": 12}[f]
if f == "Run" && (kind == 2 || kind == 4) {
expected = kind
}
if kind != expected {
return c, fmt.Errorf("eventplay: configured minigame family mismatch")
}
return c, nil
}
func (s *Service) game(path string, req []byte, session string, next *snapshot, key string) ([]byte, error) {
f := family(path)
if f == "" {
return nil, fmt.Errorf("eventplay: unknown family")
}
start := strings.HasSuffix(path, "Start")
end := strings.HasSuffix(path, "End")
runKey := session + ":" + f
uid := num(req, 2)
if end || path == "/MiniGameFieldScore" || path == "/MiniGameSurvivalPlay" || strings.Contains(path, "Skill") {
if a, ok := next.Runs[runKey]; ok {
uid = a.UID
} else {
return nil, fmt.Errorf("eventplay: no active run")
}
}
c, e := s.calendar(uid, f)
if e != nil {
return nil, e
}
uid = c.UID
stage, mode := num(req, 3), num(req, 4)
if f == "Rhythm" {
mode = num(req, 5)
}
if f == "Survival" {
stage = num(req, 5)
}
if f == "Sichuan" {
stage = num(req, 4)
}
rk := recKey(uid, stage, mode, f)
r := next.Records[rk]
if r.Clears == nil {
r = record{UID: uid, Game: c.ID, Stage: stage, Mode: mode, Family: f, Clears: map[string]bool{}}
}
if path == "/MiniGameSurvivalCharUpgrade" || path == "/MiniGameSurvivalCharUpgradeReset" {
return s.upgrade(path, req, uid, next, key)
}
if path == "/MiniGameDefenseMatching" {
matching := num(req, 3)
if matching > 2 {
return nil, fmt.Errorf("eventplay: unknown matching mode")
}
next.Runs[runKey+":matching"] = Run{UID: uid, Game: c.ID, Family: f, Session: session}
return nil, nil
}
if start {
if s.now().UnixMilli() < c.Start || s.now().UnixMilli() >= c.End {
return nil, fmt.Errorf("eventplay: season closed")
}
if _, active := next.Runs[runKey]; active {
return nil, fmt.Errorf("eventplay: run already active")
}
if e = s.validateStage(f, stage, mode); e != nil {
return nil, e
}
a := Run{UID: uid, Game: c.ID, Stage: stage, Mode: mode, Started: uint64(s.now().UnixMilli()), Family: f, Session: session, HP: 100}
if f == "Action" {
game, _ := s.design.Row("PackEventMiniGameTable", 8, c.ID)
group := num(game, 4)
rows := s.design.Rows("ActionGameStageTable", 3, group)
if len(rows) == 0 {
return nil, fmt.Errorf("eventplay: action stage group missing")
}
a.Stage = num(rows[0], 4)
a.Mode = group
a.Char = num(rows[0], 1)
}
if f == "Action" || f == "Defense" {
if locked, ok := next.Runs[runKey+":native"]; ok {
a.Stage, a.Mode, a.Char = locked.Stage, locked.Mode, locked.Char
a.Session = locked.Session
if s.rooms != nil {
if e = s.rooms.ValidateRoom(session, a.Session, uid); e != nil {
return nil, e
}
}
}
}
var startReply []byte
if f == "Field" {
d, e := s.design.Field(c.ID)
if e != nil {
return nil, e
}
a.HP = d.HP
ids := make([]uint64, 0, len(d.Objects))
for id := range d.Objects {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, id := range ids {
startReply = wire.AppendVarint(startReply, 1, id)
}
}
if f == "Survival" {
a.Char = num(req, 3)
a.MapGroup = num(req, 4)
char, e := s.design.Row("FieldMiniGameCharTable", 13, a.Char)
if e != nil {
return nil, e
}
a.HP = num(char, 20)
a.MaxHP = a.HP
a.Rerolls = num(char, 19)
a.Level = 1
a.SkillCredits = 0
a.Killed = map[uint64]uint64{}
a.Picked = map[uint64]uint64{}
a.Available = map[uint64]uint64{}
skillGroup := num(char, 3)
skills := s.design.Rows("FieldMiniGameSkillTable", 7, skillGroup)
if len(skills) == 0 {
return nil, fmt.Errorf("eventplay: character starting skill missing")
}
a.Skills = []uint64{num(skills[0], 8)}
a.SkillLevels = map[uint64]uint64{skillGroup: 1}
found := false
for _, m := range s.design.Rows("FieldMiniGameMapTable", 2, a.MapGroup) {
if num(m, 3) == a.Stage {
found = true
}
}
if !found {
return nil, fmt.Errorf("eventplay: survival map missing")
}
}
next.Runs[runKey] = a
if s.onProgress != nil {
if e = s.onProgress(343, c.ID, 1); e != nil {
return nil, e
}
if e = s.onProgress(315, c.ID, 1); e != nil {
return nil, e
}
}
next.Records[rk] = r
if f == "Survival" {
v := wire.AppendVarint(nil, 1, a.Skills[0])
v = wire.AppendVarint(v, 2, 1)
out := wire.AppendBytes(nil, 1, v)
return wire.AppendVarint(out, 2, seqSeed(a.Started)), nil
}
return startReply, nil
}
if end {
a := next.Runs[runKey]
if a.UID != uid {
return nil, fmt.Errorf("eventplay: run schedule changed")
}
if f == "Action" && (num(req, 3) != a.Mode || num(req, 4) != a.Stage || num(req, 6) != a.Char) {
return nil, fmt.Errorf("eventplay: action submitted stage/monster differs from configured start")
}
if f == "Action" || f == "Defense" {
guid, _, err := wire.Bytes(req, 2)
if err != nil {
return nil, err
}
if a.Session != session && !bytes.Equal(guid, []byte(a.Session)) {
return nil, fmt.Errorf("eventplay: native room guid mismatch")
}
if s.rooms != nil {
if e = s.rooms.ValidateRoom(session, string(guid), uid); e != nil {
return nil, e
}
}
}
score, e := s.endScore(f, req, a)
if e != nil {
return nil, e
}
if s.onProgress != nil {
switch f {
case "Survival":
for condition, count := range map[uint64]uint64{316: a.Level, 317: score} {
if e = s.onProgress(condition, c.ID, count); e != nil {
return nil, e
}
}
if num(req, 2) == 0 {
if e = s.onProgress(320, a.Stage, 1); e != nil {
return nil, e
}
}
case "Defense":
if e = s.onProgress(322, c.ID, score); e != nil {
return nil, e
}
case "Action":
if e = s.onProgress(323, a.Char, 1); e != nil {
return nil, e
}
for condition, field := range map[uint64]int{326: 9, 327: 10, 329: 12, 330: 13, 333: 14, 334: 15, 335: 16, 336: 17} {
if count := num(req, field); count > 0 {
if e = s.onProgress(condition, a.Char, count); e != nil {
return nil, e
}
}
}
}
}
rk = recKey(uid, a.Stage, a.Mode, f)
r = next.Records[rk]
if r.Clears == nil {
r.Clears = map[string]bool{}
}
if score > r.Best {
r.Best = score
}
var out []byte
point, rewards, e := s.scoreRewards(c.ID, score)
if e != nil {
return nil, e
}
if f == "Defense" {
for _, row := range s.design.Tables["MGDRewardTable"] {
id, wave := num(row, 1), num(row, 6)
ck := fmt.Sprintf("wave:%d", id)
if wave <= score && !r.Clears[ck] {
rr, err := gamedata.EventPlayRewards(row, 5, 3, 2)
if err != nil {
return nil, err
}
b, err := s.grant("eventplay:"+key+":"+ck, rr)
if err != nil {
return nil, err
}
out = wire.AppendBytes(out, 1, b)
out = wire.AppendVarint(out, 2, id)
r.Clears[ck] = true
}
}
}
if f == "Survival" {
if num(req, 5) > a.Coin || num(req, 6) > a.Exp {
return nil, fmt.Errorf("eventplay: end coin/exp exceeds committed play progress")
}
out = wire.AppendVarint(out, 2, a.Coin)
out = wire.AppendVarint(out, 3, a.Exp)
if num(req, 2) == 0 {
r.Clears[fmt.Sprintf("map:%d:%d", a.MapGroup, a.Stage)] = true
for _, m := range s.design.Rows("FieldMiniGameMapTable", 2, a.MapGroup) {
if num(m, 3) != a.Stage {
continue
}
rr, err := gamedata.EventPlayRewards(m, 8, 7, 6)
if err != nil {
return nil, err
}
if len(rr) > 0 {
b, err := s.grant(fmt.Sprintf("eventplay:map:%d:%d:%d", uid, a.MapGroup, a.Stage), rr)
if err != nil {
return nil, err
}
out = wire.AppendBytes(out, 1, b)
}
}
}
if a.Coin > 0 {
b, err := s.grant("eventplay:"+key+":coin", []gamedata.BattleReward{{Type: 43, Count: a.Coin}})
if err != nil {
return nil, err
}
out = wire.AppendBytes(out, 1, b)
}
}
day := uint64(s.now().UnixMilli()) / 86400000
if r.Date != day {
r.Paid = 0
}
if point > r.Paid {
_, previous, e := s.scoreRewards(c.ID, r.Paid)
if e != nil {
return nil, e
}
bundle, e := s.grant("eventplay:"+key, rewardDifference(previous, rewards))
if e != nil {
return nil, e
}
field := 1
if f == "Run" {
field = 3
} else if f == "Field" {
field = 5
} else if f == "Action" {
field = 3
} else if f == "Hopscotch" {
field = 4
}
if f == "Rhythm" {
r.PendingBundle = append(r.PendingBundle, bundle...)
} else {
out = wire.AppendBytes(out, field, bundle)
}
r.Paid = point
r.Date = day
}
switch f {
case "Run":
out = wire.AppendVarint(out, 1, r.Best)
out = wire.AppendVarint(out, 2, r.Paid)
case "Field":
out = wire.AppendVarint(out, 1, uid)
out = wire.AppendVarint(out, 2, score)
out = wire.AppendVarint(out, 3, r.Best)
out = wire.AppendVarint(out, 4, r.Paid)
case "Sichuan":
out = wire.AppendDouble(out, 4, float64(r.Best))
case "Rhythm":
v := wire.AppendVarint(nil, 1, a.Stage)
v = wire.AppendVarint(v, 2, a.Mode)
v = wire.AppendVarint(v, 3, r.Best)
v = wire.AppendVarint(v, 4, num(req, 2))
v = wire.AppendVarint(v, 5, num(req, 6))
out = wire.AppendBytes(out, 1, v)
case "Action":
out = wire.AppendVarint(out, 1, r.Best)
case "Hopscotch":
v := wire.AppendVarint(nil, 1, a.Stage)
if score > 0 {
v = wire.AppendVarint(v, 2, 1)
}
out = wire.AppendBytes(out, 2, v)
}
next.Records[rk] = r
delete(next.Runs, runKey)
if s.rooms != nil && (f == "Action" || f == "Defense") {
if e = s.rooms.CompleteRoom(session, a.Session, score); e != nil {
return nil, e
}
}
return out, nil
}
if strings.Contains(path, "Ranking") || strings.Contains(path, "RecordInfo") {
return rankingWire(next, uid, f, strings.Contains(path, "RecordInfo")), nil
}
if strings.HasSuffix(path, "Info") {
var out []byte
if f == "Rhythm" {
for k, v := range next.Records {
if v.UID == uid && v.Family == f && len(v.PendingBundle) > 0 {
out = wire.AppendBytes(out, 2, v.PendingBundle)
v.PendingBundle = nil
next.Records[k] = v
}
}
}
if f != "Run" && f != "Field" {
if _, exists := next.Records[rk]; !exists {
next.Records[rk] = r
}
return append(out, specializedInfo(next, uid, f)...), nil
}
for _, v := range next.Records {
if v.UID == uid && v.Family == f {
out = wire.AppendBytes(out, 1, recordWire(v))
}
}
if len(out) == 0 {
next.Records[rk] = r
out = wire.AppendBytes(out, 1, recordWire(r))
}
return out, nil
}
if strings.Contains(path, "QuickReward") || path == "/MiniGameFieldReward" {
return s.claimScore(path, uid, f, next, key)
}
return s.progress(path, req, runKey, next, key)
}
+219
View File
@@ -0,0 +1,219 @@
package eventplay
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"encoding/binary"
"testing"
"time"
)
func nums(b []byte, n int) ([]uint64, error) { return list(b, n) }
type econ struct{ cost, granted uint64 }
func (e *econ) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) {
for _, x := range c {
e.cost += x.Count
}
for _, x := range r {
e.granted += x.Count
}
return wire.AppendVarint(nil, 1, 99), nil
}
func makeService(t *testing.T) (*Service, *econ) {
t.Helper()
reg := events.NewRegistry()
if e := reg.Replace([]events.Schedule{{UID: 7, Type: 9, ID: 1, Start: 1, End: 9999999}, {UID: 8, Type: 10, ID: 1, Start: 1, End: 9999999}, {UID: 9, Type: 11, ID: 1, Start: 1, End: 9999999}}); e != nil {
t.Fatal(e)
}
e := &econ{}
s := &Service{store: stateio.NewMemory(), registry: reg, economy: e, now: func() time.Time { return time.UnixMilli(100) }, design: &gamedata.EventPlayCatalog{Tables: map[string][][]byte{}}, state: snapshot{1, map[string]record{}, map[string]Run{}, map[string]reply{}, map[string]bool{}, map[string]uint64{}}}
return s, e
}
func TestBattleRewardFieldFiveClearInfoSixteenAndRetry(t *testing.T) {
s, e := makeService(t)
row := wire.AppendVarint(nil, 4, 1)
row = wire.AppendVarint(row, 5, 1)
row = wire.AppendVarint(row, 1, 88)
row = wire.AppendVarint(row, 3, 1)
row = wire.AppendVarint(row, 9, 4)
row = wire.AppendVarint(row, 8, 50)
s.design.Tables["PackEventBattleTable"] = [][]byte{row}
req := wire.AppendVarint(nil, 5, 17)
req = wire.AppendVarint(req, 8, 1)
req = wire.AppendVarint(req, 9, 1)
req = wire.AppendVarint(req, 4, 88)
if _, err := s.EnterBattle(req, "sid:1"); err != nil {
t.Fatal(err)
}
end := wire.AppendVarint(nil, 2, 1)
a, err := s.CompleteBattle(end, "sid:1")
if err != nil {
t.Fatal(err)
}
if _, ok, _ := wire.Bytes(a, 5); !ok {
t.Fatal("reward_bundle absent field5")
}
if _, ok, _ := wire.Bytes(a, 3); ok {
t.Fatal("bundle polluted CharInfo field3")
}
if _, ok, _ := wire.Bytes(a, 16); !ok {
t.Fatal("clear info absent")
}
b, err := s.CompleteBattle(end, "sid:1")
if err != nil || !bytes.Equal(a, b) || e.cost != 1 || e.granted != 50 {
t.Fatalf("retry %v cost%d grant%d", err, e.cost, e.granted)
}
}
func TestStoryClearPersistentOnceAndChangedRetry(t *testing.T) {
s, e := makeService(t)
r := wire.AppendVarint(nil, 1, 1)
r = wire.AppendVarint(r, 2, 1)
r = wire.AppendVarint(r, 11, 4)
r = wire.AppendVarint(r, 9, 10)
s.design.Tables["PackEventStoryTable"] = [][]byte{r}
req := wire.AppendVarint(nil, 1, 1)
req = wire.AppendVarint(req, 2, 8)
req = wire.AppendVarint(req, 3, 1)
req = wire.AppendVarint(req, 4, 1)
if _, _, _, err := s.HandleSession("/PackEventStoryClear", req, "sid"); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/PackEventStoryClear", req, "sid"); err != nil {
t.Fatal(err)
}
if e.granted != 10 {
t.Fatal("duplicate story award")
}
bad := wire.AppendVarint(req, 9, 1)
if _, _, _, err := s.HandleSession("/PackEventStoryClear", bad, "sid"); err == nil {
t.Fatal("changed retry accepted")
}
}
func TestScoreRewardUpgradeDifference(t *testing.T) {
r := rewardDifference([]gamedata.BattleReward{{Type: 4, Count: 10}}, []gamedata.BattleReward{{Type: 4, Count: 15}})
if len(r) != 1 || r[0].Count != 5 {
t.Fatal(r)
}
}
func TestEndScoreWireFieldsAcrossFamilies(t *testing.T) {
s, _ := makeService(t)
for _, tc := range []struct {
family string
field int
value uint64
}{{"Run", 3, 20}, {"Action", 7, 30}, {"Sichuan", 4, 40}, {"Hopscotch", 2, 50}, {"Defense", 3, 60}} {
got, e := s.endScore(tc.family, wire.AppendVarint(nil, tc.field, tc.value), Run{})
if e != nil || got != tc.value {
t.Fatalf("%s score=%d err%v", tc.family, got, e)
}
}
if _, e := s.endScore("Hopscotch", wire.AppendVarint(nil, 2, 10001), Run{}); e == nil {
t.Fatal("captured area accepted beyond100percent")
}
}
func TestRhythmEndScoreRejectsStaticScoreAndJudgmentOverflow(t *testing.T) {
s, _ := makeService(t)
row := wire.AppendVarint(nil, 5, 1)
row = wire.AppendVarint(row, 8, 1000)
row = wire.AppendVarint(row, 10, 3)
s.design.Tables["RhythmGameMusicTable"] = [][]byte{row}
if _, e := s.endScore("Rhythm", wire.AppendVarint(nil, 4, 1001), Run{Stage: 1}); e == nil {
t.Fatal("score above staticmax accepted")
}
judgment := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 4)
req := wire.AppendVarint(nil, 4, 500)
req = wire.AppendBytes(req, 3, judgment)
if _, e := s.endScore("Rhythm", req, Run{Stage: 1}); e == nil {
t.Fatal("more judgments than designed notes accepted")
}
}
func TestEventBattleChallengesPersistAndAwardOnce(t *testing.T) {
s, e := makeService(t)
row := wire.AppendVarint(nil, 4, 1)
row = wire.AppendVarint(row, 5, 1)
row = wire.AppendVarint(row, 1, 88)
row = wire.AppendVarint(row, 3, 1)
row = wire.AppendVarint(row, 9, 4)
row = wire.AppendVarint(row, 8, 5)
s.design.Tables["PackEventBattleTable"] = [][]byte{row}
s.AttachBattleChallenges(gamedata.EventBattleChallenges{88: {{Type: 8, Reward: gamedata.BattleReward{Type: 3, Count: 25}}, {Type: 2, Value1: 8, Reward: gamedata.BattleReward{Type: 3, Count: 25}}}})
start := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 5, 17), 8, 1), 9, 1), 4, 88)
if _, err := s.EnterBattle(start, "sid:first"); err != nil {
t.Fatal(err)
}
end := wire.AppendVarint(nil, 2, 1)
end = wire.AppendVarint(end, 5, 0)
body, err := s.CompleteBattle(end, "sid:first")
if err != nil {
t.Fatal(err)
}
info, _, _ := wire.Bytes(body, 16)
indexes, err := challengeTestIndexes(info)
if err != nil || len(indexes) != 1 || indexes[0] != 0 || e.granted != 30 {
t.Fatalf("challenge field16=%x reward%d err%v", info, e.granted, err)
}
if _, err = s.EnterBattle(start, "sid:second"); err != nil {
t.Fatal(err)
}
end = wire.AppendVarint(end, 5, 1)
body, err = s.CompleteBattle(end, "sid:second")
if err != nil {
t.Fatal(err)
}
info, _, _ = wire.Bytes(body, 16)
indexes, _ = challengeTestIndexes(info)
if len(indexes) != 2 || e.granted != 60 {
t.Fatalf("cumulative indexes %+v grant%d", indexes, e.granted)
}
if _, err = s.EnterBattle(start, "sid:third"); err != nil {
t.Fatal(err)
}
if _, err = s.CompleteBattle(end, "sid:third"); err != nil {
t.Fatal(err)
}
if e.granted != 65 {
t.Fatal("challenge reward paid twice")
}
}
func TestEventBattleChallengeUnknownAndDuplicateRejectedBeforeCost(t *testing.T) {
s, e := makeService(t)
row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 4, 1), 5, 1), 1, 88)
s.design.Tables["PackEventBattleTable"] = [][]byte{row}
s.AttachBattleChallenges(gamedata.EventBattleChallenges{88: {{Type: 8, Reward: gamedata.BattleReward{Type: 3, Count: 25}}}})
start := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 5, 17), 8, 1), 9, 1), 4, 88)
s.EnterBattle(start, "sid:x")
bad := wire.AppendVarint(wire.AppendVarint(nil, 2, 1), 5, 1)
if _, err := s.CompleteBattle(bad, "sid:x"); err == nil {
t.Fatal("unknownchallenge")
}
bad = wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, 1), 5, 0), 5, 0)
if _, err := s.CompleteBattle(bad, "sid:x"); err == nil {
t.Fatal("duplicatechallenge")
}
if e.cost != 0 || e.granted != 0 {
t.Fatal("badchallenge mutated economic state")
}
}
func challengeTestIndexes(b []byte) ([]uint64, error) {
var out []uint64
err := wire.Walk(b, func(f wire.Field) error {
if f.Number == 4 {
v, _, e := wire.Varint(wire.AppendVarint(nil, 4, numFieldTest(f)), 4)
if e != nil {
return e
}
out = append(out, v)
}
return nil
})
return out, err
}
func numFieldTest(f wire.Field) uint64 { v, _ := binary.Uvarint(f.Value); return v }
+35
View File
@@ -0,0 +1,35 @@
package eventplay
import "bd2server/internal/server/events"
func (s *Service) AttachProgress(fn func(condition, sub, count uint64) error) { s.onProgress = fn }
func (s *Service) AssociatedMissionGroup(c events.Schedule) (uint64, error) {
if c.Type != 11 {
return 0, nil
}
game, e := s.design.Row("PackEventMiniGameTable", 8, c.ID)
if e != nil {
return 0, e
}
switch num(game, 3) {
case 5:
row, e := s.design.Row("FieldMiniGameSurvivalTable", 9, num(game, 4))
if e != nil {
return 0, e
}
return num(row, 8), nil
case 7:
if len(s.design.Tables["MGDDefaultTable"]) > 0 {
return num(s.design.Tables["MGDDefaultTable"][0], 5), nil
}
case 8:
if len(s.design.Tables["ActionGameDefaultTable"]) > 0 {
return num(s.design.Tables["ActionGameDefaultTable"][0], 8), nil
}
case 12:
if len(s.design.Tables["HopscotchDefaultTable"]) > 0 {
return num(s.design.Tables["HopscotchDefaultTable"][0], 20), nil
}
}
return 0, nil
}
+162
View File
@@ -0,0 +1,162 @@
package eventplay
import (
"bd2server/internal/server/wire"
"fmt"
"sort"
"strings"
)
func specializedInfo(st *snapshot, uid uint64, f string) []byte {
var out []byte
if f == "Action" || f == "Survival" || f == "Defense" {
out = wire.AppendVarint(out, 1, uid)
}
for _, r := range st.Records {
if r.UID != uid || r.Family != f {
continue
}
switch f {
case "Sichuan":
v := wire.AppendVarint(nil, 1, uid)
v = wire.AppendVarint(v, 6, r.Best)
out = wire.AppendBytes(out, 1, v)
case "Rhythm":
v := wire.AppendVarint(nil, 1, r.Stage)
v = wire.AppendVarint(v, 2, r.Mode)
v = wire.AppendVarint(v, 3, r.Best)
out = wire.AppendBytes(out, 1, v)
case "Action":
v := wire.AppendVarint(nil, 1, r.Stage)
v = wire.AppendVarint(v, 2, r.Best)
out = wire.AppendBytes(out, 2, v)
case "Hopscotch":
v := wire.AppendVarint(nil, 1, r.Stage)
if r.Best > 0 {
v = wire.AppendVarint(v, 2, 1)
}
out = wire.AppendBytes(out, 2, v)
case "Survival":
out = wire.AppendVarint(out, 4, r.Best)
for stage, clear := range r.Clears {
if !clear {
continue
}
var group, id uint64
if _, e := fmt.Sscanf(stage, "map:%d:%d", &group, &id); e == nil {
v := wire.AppendVarint(nil, 1, group)
v = wire.AppendVarint(v, 2, id)
out = wire.AppendBytes(out, 8, v)
}
}
}
}
if f == "Survival" {
prefix := fmt.Sprintf("%d:", uid)
for key, level := range st.Upgrades {
if !strings.HasPrefix(key, prefix) {
continue
}
var id uint64
_, _ = fmt.Sscanf(key[len(prefix):], "%d", &id)
v := wire.AppendVarint(nil, 1, id)
v = wire.AppendVarint(v, 2, level)
out = wire.AppendBytes(out, 10, v)
}
}
return out
}
func rankingWire(st *snapshot, uid uint64, f string, record bool) []byte {
keys := []string{}
for k, r := range st.Records {
if r.UID == uid && r.Family == f && r.Best > 0 {
keys = append(keys, k)
}
}
sort.Strings(keys)
var out []byte
for _, k := range keys {
r := st.Records[k]
var b []byte
switch f {
case "Survival":
b = wire.AppendVarint(nil, 1, 1)
b = wire.AppendVarint(b, 4, r.Best)
if record {
b = wire.AppendVarint(nil, 1, r.Best)
b = wire.AppendDouble(b, 2, 100)
}
case "Hopscotch":
b = wire.AppendVarint(nil, 1, 1)
b = wire.AppendVarint(b, 4, r.Best)
if record {
b = wire.AppendVarint(nil, 1, r.Stage)
b = wire.AppendVarint(b, 2, r.Best)
b = wire.AppendDouble(b, 4, 100)
}
case "Action":
b = wire.AppendVarint(nil, 3, 1)
b = wire.AppendVarint(b, 4, r.Best)
if record {
b = wire.AppendVarint(nil, 1, r.Stage)
b = wire.AppendVarint(b, 2, r.Best)
b = wire.AppendDouble(b, 3, 100)
}
case "Sichuan":
b = wire.AppendVarint(nil, 7, 1)
b = wire.AppendVarint(b, 8, r.Best)
if record {
b = wire.AppendVarint(nil, 1, r.Best)
b = wire.AppendDouble(b, 2, 100)
}
case "Rhythm":
b = wire.AppendVarint(nil, 1, 1)
b = wire.AppendVarint(b, 4, r.Best)
if record {
b = wire.AppendVarint(nil, 1, r.Best)
b = wire.AppendVarint(b, 2, r.Stage)
b = wire.AppendDouble(b, 3, 100)
}
default:
b = wire.AppendVarint(nil, 1, 1)
b = wire.AppendVarint(b, 4, r.Best)
}
out = wire.AppendBytes(out, 1, b)
}
return out
}
func (s *Service) claimScore(path string, uid uint64, f string, next *snapshot, key string) ([]byte, error) {
for k, r := range next.Records {
if r.UID != uid || r.Family != f || r.Best == 0 {
continue
}
point, rewards, e := s.scoreRewards(r.Game, r.Best)
if e != nil {
return nil, e
}
day := uint64(s.now().UnixMilli()) / 86400000
if r.Date != day {
r.Paid = 0
}
if point <= r.Paid {
return nil, fmt.Errorf("eventplay: daily best reward already paid")
}
_, old, e := s.scoreRewards(r.Game, r.Paid)
if e != nil {
return nil, e
}
bundle, e := s.grant("eventplay:"+key, rewardDifference(old, rewards))
if e != nil {
return nil, e
}
r.Paid = point
r.Date = day
next.Records[k] = r
if strings.HasSuffix(path, "QuickReward") {
return wire.AppendBytes(nil, 1, bundle), nil
}
out := wire.AppendVarint(nil, 1, point)
return wire.AppendBytes(out, 2, bundle), nil
}
return nil, fmt.Errorf("eventplay: no completed score to reward")
}
+39
View File
@@ -0,0 +1,39 @@
package eventplay
import (
"encoding/json"
"fmt"
)
// RoomRuntime is implemented by the native matching/relay service. HTTP
// gameplay settles only runs whose native room is authenticated.
type RoomRuntime interface {
ValidateRoom(session, guid string, uid uint64) error
CompleteRoom(session, guid string, score uint64) error
}
func (s *Service) AttachRooms(r RoomRuntime) { s.rooms = r }
// LockRoomRun installs the stage selected by native room matching before the
// client's HTTP Start. It does not award rewards or trust HTTP stage changes.
func (s *Service) LockRoomRun(session, family, guid string, uid, group, stage, monster uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
if family != "Defense" && family != "Action" {
return fmt.Errorf("eventplay: invalid room family")
}
c, e := s.calendar(uid, family)
if e != nil {
return e
}
raw, _ := json.Marshal(s.state)
var next snapshot
_ = json.Unmarshal(raw, &next)
next.Runs[session+":"+family+":native"] = Run{UID: uid, Game: c.ID, Stage: stage, Mode: group, Char: monster, Family: family, Session: guid}
raw, _ = json.Marshal(next)
if e = s.store.Save("eventplay", raw); e != nil {
return e
}
s.state = next
return nil
}
+58
View File
@@ -0,0 +1,58 @@
package eventplay
var codes = map[string]int{
"/MiniGameUserRecordInfo": 635,
"/MiniGameRanking": 391,
"/MiniGameRelayServerInfo": 0,
"/EventHubInfo": 222,
"/MiniEventHubInfo": 534,
"/MiniGameActionEnd": 423,
"/MiniGameActionInfo": 421,
"/MiniGameActionRanking": 0,
"/MiniGameActionStart": 422,
"/MiniGameActionUserRecordInfo": 638,
"/MiniGameDefenseEnd": 395,
"/MiniGameDefenseInfo": 393,
"/MiniGameDefenseMatching": 0,
"/MiniGameDefenseStart": 394,
"/MiniGameFieldEnd": 250,
"/MiniGameFieldInfo": 247,
"/MiniGameFieldQuickReward": 251,
"/MiniGameFieldReward": 0,
"/MiniGameFieldScore": 249,
"/MiniGameFieldStart": 248,
"/MiniGameHopscotchGameEnd": 544,
"/MiniGameHopscotchGameStart": 543,
"/MiniGameHopscotchInfo": 541,
"/MiniGameHopscotchRankingInfo": 542,
"/MiniGameHopscotchUserRecordInfo": 640,
"/MiniGameHubInfo": 390,
"/MiniGameRhythmInfo": 428,
"/MiniGameRhythmPlayEnd": 430,
"/MiniGameRhythmPlayStart": 429,
"/MiniGameRhythmRanking": 0,
"/MiniGameRhythmUserRecordInfo": 639,
"/MiniGameRunEnd": 330,
"/MiniGameRunInfo": 328,
"/MiniGameRunQuickReward": 331,
"/MiniGameRunStart": 329,
"/MiniGameSichuanEnd": 380,
"/MiniGameSichuanInfo": 378,
"/MiniGameSichuanRanking": 383,
"/MiniGameSichuanStart": 379,
"/MiniGameSichuanUserRecordInfo": 637,
"/MiniGameSurvivalCharUpgrade": 369,
"/MiniGameSurvivalCharUpgradeReset": 389,
"/MiniGameSurvivalEnd": 350,
"/MiniGameSurvivalInfo": 348,
"/MiniGameSurvivalPlay": 364,
"/MiniGameSurvivalRanking": 368,
"/MiniGameSurvivalSkillSelectList": 370,
"/MiniGameSurvivalSkillUp": 365,
"/MiniGameSurvivalStart": 349,
"/MiniGameSurvivalUserRecordInfo": 636,
"/PackEventBattleInfo": 214,
"/PackEventStoryClear": 221,
"/PackEventStoryInfo": 220,
"/PackEventStoryReplayClear": 0,
}
+237
View File
@@ -0,0 +1,237 @@
package eventplay
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"fmt"
"math"
"strings"
)
func (s *Service) validateStage(f string, id, mode uint64) error {
switch f {
case "Rhythm":
r, e := s.design.Row("RhythmGameMusicTable", 5, id)
if e != nil {
return e
}
if mode > 2 {
return fmt.Errorf("eventplay: unknown rhythm mode")
}
_ = r
case "Hopscotch":
_, e := s.design.Row("HopscotchStageTable", 6, id)
return e
case "Sichuan":
_, e := s.design.Row("SichuanStageTable", 2, id)
return e
case "Survival":
if len(s.design.Tables["FieldMiniGameSurvivalTable"]) == 0 {
return fmt.Errorf("eventplay: survival design missing")
}
case "Action":
if len(s.design.Tables["ActionGameDefaultTable"]) == 0 {
return fmt.Errorf("eventplay: action design missing")
}
}
return nil
}
func (s *Service) endScore(f string, req []byte, a Run) (uint64, error) {
var score uint64
switch f {
case "Run":
score = num(req, 3)
case "Field":
score = a.Score
case "Action":
score = num(req, 7)
case "Rhythm":
score = num(req, 4)
r, e := s.design.Row("RhythmGameMusicTable", 5, a.Stage)
if e != nil {
return 0, e
}
maxField := 8
if a.Mode == 1 {
maxField = 2
} else if a.Mode == 2 {
maxField = 1
}
if score > num(r, maxField) {
return 0, fmt.Errorf("eventplay: rhythm score exceeds static maximum")
}
judgments := uint64(0)
err := wire.Walk(req, func(f wire.Field) error {
if f.Number == 3 {
if f.Type != 2 {
return wire.ErrMalformed
}
typ, count := num(f.Value, 1), num(f.Value, 2)
if typ > 10 || count > math.MaxInt32 {
return fmt.Errorf("eventplay: invalid note judgment")
}
judgments += count
}
return nil
})
if err != nil {
return 0, err
}
noteField := 10
if a.Mode > 0 {
noteField = 4
}
if judgments > num(r, noteField) {
return 0, fmt.Errorf("eventplay: judgment count exceeds designed notes")
}
case "Sichuan":
score = num(req, 4)
case "Hopscotch":
score = num(req, 2)
if score > 10000 {
return 0, fmt.Errorf("eventplay: captured area exceeds 100 percent")
}
case "Survival":
score = num(req, 3)
d := s.design.Tables["FieldMiniGameSurvivalTable"][0]
if score > num(d, 24) {
return 0, fmt.Errorf("eventplay: survival time exceeds design")
}
case "Defense":
score = num(req, 3)
}
if score > math.MaxInt32 {
return 0, fmt.Errorf("eventplay: submitted score exceeds protocol range")
}
return score, nil
}
func (s *Service) scoreRewards(game, score uint64) (uint64, []gamedata.BattleReward, error) {
d, e := s.design.Row("PackEventMiniGameTable", 8, game)
if e != nil {
return 0, nil, e
}
return s.design.ScoreRewards(num(d, 10), score)
}
func (s *Service) progress(path string, req []byte, rk string, next *snapshot, key string) ([]byte, error) {
a, ok := next.Runs[rk]
if !ok {
return nil, fmt.Errorf("eventplay: no active run")
}
var out []byte
switch path {
case "/MiniGameFieldScore":
d, e := s.design.Field(a.Game)
if e != nil {
return nil, e
}
ids, e := list(req, 3)
if e != nil || len(ids) == 0 {
return nil, fmt.Errorf("eventplay: missing field objects")
}
for _, id := range ids {
v, ok := d.Objects[id]
if !ok || has(a.Objects, id) {
return nil, fmt.Errorf("eventplay: object invalid or already scored")
}
a.Objects = append(a.Objects, id)
if v.Type == 1 {
a.Score += v.Point
} else if v.Type == 2 {
a.HP += v.Point
}
}
out = wire.AppendVarint(nil, 1, a.Score)
out = wire.AppendVarint(out, 2, a.HP)
case "/MiniGameSurvivalPlay":
exp, coin, e := s.survivalProgress(req, &a)
if e != nil {
return nil, e
}
if s.onProgress != nil {
total := uint64(0)
for id, count := range a.Killed {
previous := next.Runs[rk].Killed[id]
if count > previous {
total += count - previous
}
}
if total > 0 {
if e = s.onProgress(319, a.Game, total); e != nil {
return nil, e
}
}
}
out = wire.AppendVarint(nil, 1, coin)
out = wire.AppendVarint(out, 2, exp)
case "/MiniGameSurvivalSkillSelectList":
var e error
out, e = s.survivalOffers(&a)
if e != nil {
return nil, e
}
case "/MiniGameSurvivalSkillUp":
id := num(req, 2)
if a.SkillCredits == 0 || !has(a.Offers, id) {
return nil, fmt.Errorf("eventplay: skill not earned or offered")
}
group, err := s.design.Row("FieldMiniGameSkillGroupTable", 1, id)
if err != nil {
return nil, fmt.Errorf("eventplay: invalid skill")
}
if item := num(req, 3); item != 0 {
if _, e := s.design.Row("FieldMiniGameSurvivalItemTable", 1, item); e != nil {
return nil, e
}
}
if a.SkillLevels == nil {
a.SkillLevels = map[uint64]uint64{}
}
level := a.SkillLevels[id] + 1
if level > num(group, 2) {
return nil, fmt.Errorf("eventplay: survival skill at maximum")
}
capacity := uint64(0)
for _, r := range s.design.Tables["FieldMiniGameSurvivalTable"] {
if num(r, 21) > capacity {
capacity = num(r, 21)
}
}
if !has(a.Skills, id) && uint64(len(a.SkillLevels)) >= capacity {
return nil, fmt.Errorf("eventplay: survival skill capacity reached")
}
a.SkillLevels[id] = level
a.SkillCredits--
a.Offers = nil
if level == num(group, 2) && s.onProgress != nil {
if e := s.onProgress(318, a.Game, 1); e != nil {
return nil, e
}
if e := s.onProgress(321, id, 1); e != nil {
return nil, e
}
}
if !has(a.Skills, id) {
a.Skills = append(a.Skills, id)
}
v := wire.AppendVarint(nil, 1, id)
v = wire.AppendVarint(v, 2, level)
out = wire.AppendBytes(nil, 1, v)
default:
if strings.Contains(path, "QuickReward") || strings.Contains(path, "Reward") {
return nil, fmt.Errorf("eventplay: reward requires a completed unpaid score record")
}
return nil, fmt.Errorf("eventplay: unsupported run transition")
}
next.Runs[rk] = a
_ = key
return out, nil
}
func has(a []uint64, id uint64) bool {
for _, x := range a {
if x == id {
return true
}
}
return false
}
func seqSeed(n uint64) uint64 { return n & 2147483647 }
+64
View File
@@ -0,0 +1,64 @@
package eventplay
import (
"bd2server/internal/server/wire"
"sort"
)
func summaryWire(st *snapshot, record bool) []byte {
keys := []string{}
for k, v := range st.Records {
if v.Best > 0 {
keys = append(keys, k)
}
}
sort.Strings(keys)
var out []byte
for _, k := range keys {
v := st.Records[k]
field := map[string]int{"Survival": 1, "Sichuan": 2, "Action": 3, "Rhythm": 5, "Hopscotch": 6}[v.Family]
if field == 0 {
continue
}
var b []byte
switch v.Family {
case "Survival":
b = wire.AppendVarint(nil, 2, v.Best)
case "Sichuan":
b = wire.AppendDouble(nil, 2, float64(v.Best))
case "Action":
b = wire.AppendVarint(nil, 2, v.Best)
b = wire.AppendVarint(b, 3, v.Stage)
case "Rhythm":
b = wire.AppendVarint(nil, 2, v.Best)
b = wire.AppendVarint(b, 3, v.Stage)
case "Hopscotch":
b = wire.AppendVarint(nil, 1, v.Stage)
b = wire.AppendVarint(b, 3, v.Best)
}
if record {
switch v.Family {
case "Survival":
b = wire.AppendVarint(nil, 1, v.Best)
b = wire.AppendDouble(b, 2, 100)
case "Sichuan":
b = wire.AppendDouble(nil, 1, float64(v.Best))
b = wire.AppendDouble(b, 2, 100)
case "Action":
b = wire.AppendVarint(nil, 1, v.Stage)
b = wire.AppendVarint(b, 2, v.Best)
b = wire.AppendDouble(b, 3, 100)
case "Rhythm":
b = wire.AppendVarint(nil, 1, v.Best)
b = wire.AppendVarint(b, 2, v.Stage)
b = wire.AppendDouble(b, 3, 100)
case "Hopscotch":
b = wire.AppendVarint(nil, 1, v.Stage)
b = wire.AppendVarint(b, 2, v.Best)
b = wire.AppendDouble(b, 4, 100)
}
}
out = wire.AppendBytes(out, field, b)
}
return out
}
+219
View File
@@ -0,0 +1,219 @@
package eventplay
import (
"bd2server/internal/server/wire"
"encoding/binary"
"fmt"
"math"
"sort"
)
func (s *Service) survivalProgress(req []byte, a *Run) (uint64, uint64, error) {
char, e := s.design.Row("FieldMiniGameCharTable", 13, a.Char)
if e != nil {
return 0, 0, e
}
var mapRow []byte
for _, r := range s.design.Rows("FieldMiniGameMapTable", 2, a.MapGroup) {
if num(r, 3) == a.Stage {
mapRow = r
}
}
if mapRow == nil {
return 0, 0, fmt.Errorf("eventplay: survival map missing")
}
monsters := s.design.Rows("FieldMiniGameMonsterTable", 2, num(mapRow, 4))
if a.Killed == nil {
a.Killed = map[uint64]uint64{}
}
if a.Picked == nil {
a.Picked = map[uint64]uint64{}
}
if a.Available == nil {
a.Available = map[uint64]uint64{}
}
elapsed := uint64(0)
if uint64(s.now().UnixMilli()) > a.Started {
elapsed = (uint64(s.now().UnixMilli()) - a.Started) / 1000
}
e = wire.Walk(req, func(f wire.Field) error {
if f.Number != 6 {
return nil
}
if f.Type != 2 {
return wire.ErrMalformed
}
id, count := num(f.Value, 1), num(f.Value, 2)
if count == 0 {
return fmt.Errorf("eventplay: empty kill count")
}
var row []byte
for _, r := range monsters {
if num(r, 5) == id {
row = r
break
}
}
if row == nil {
return fmt.Errorf("eventplay: monster outside survival map")
}
max := num(row, 12)
repeats := num(row, 11)
start := num(row, 16)
interval := num(row, 13)
if elapsed < start {
return fmt.Errorf("eventplay: monster not spawned yet")
}
waves := uint64(1)
if interval > 0 {
waves += (elapsed - start) / interval
}
if repeats > 0 && waves > repeats {
waves = repeats
}
if max == 0 || a.Killed[id] > max*waves || count > max*waves-a.Killed[id] {
return fmt.Errorf("eventplay: kill count exceeds designed spawns")
}
a.Killed[id] += count
box := num(row, 6)
if box != 0 {
items, err := s.boxItems(box)
if err != nil {
return err
}
for _, item := range items {
a.Available[item] += count
}
}
return nil
})
if e != nil {
return 0, 0, e
}
exp, coin := uint64(0), uint64(0)
healing := uint64(0)
e = wire.Walk(req, func(f wire.Field) error {
if f.Number != 5 {
return nil
}
if f.Type != 2 {
return wire.ErrMalformed
}
id, count := num(f.Value, 1), num(f.Value, 2)
if count == 0 || count > a.Available[id] {
return fmt.Errorf("eventplay: item count exceeds committed drops")
}
row, e := s.design.Row("FieldMiniGameSurvivalItemTable", 1, id)
if e != nil {
return e
}
v := num(row, 4)
if count > math.MaxInt32 || v > math.MaxInt32/count {
return fmt.Errorf("eventplay: item value overflow")
}
a.Available[id] -= count
a.Picked[id] += count
switch num(row, 3) {
case 0:
healing += v * count
case 3:
exp += uint64(math.Floor(float64(v*count) * doubleField(char, 8, 1)))
case 4:
coin += uint64(math.Floor(float64(v*count) * doubleField(char, 10, 1)))
case 5:
a.SkillCredits += count
}
return nil
})
if e != nil {
return 0, 0, e
}
boxes, e := list(req, 7)
if e != nil {
return 0, 0, e
}
for _, id := range boxes {
if _, e := s.design.Row("FieldMiniGameSurvivalBoxTable", 2, id); e != nil {
return 0, 0, e
}
}
hp := num(req, 2)
if hp > a.MaxHP || hp > a.HP+healing {
return 0, 0, fmt.Errorf("eventplay: invalid survival HP")
}
a.HP = hp
a.Exp += exp
a.Coin += coin
a.LevelExp += exp
if a.Exp > math.MaxInt32 || a.Coin > math.MaxInt32 {
return 0, 0, fmt.Errorf("eventplay: survival progress exceeds protocol range")
}
growth := num(char, 2)
for {
var current, next []byte
for _, r := range s.design.Rows("FieldMiniGameCharLevelTable", 1, growth) {
if num(r, 2) == a.Level {
current = r
}
if num(r, 2) == a.Level+1 {
next = r
}
}
if current == nil || next == nil || num(current, 3) == 0 || a.LevelExp < num(current, 3) {
break
}
a.LevelExp -= num(current, 3)
a.Level++
a.SkillCredits++
}
return exp, coin, nil
}
func doubleField(b []byte, n int, def float64) float64 {
v := def
_ = wire.Walk(b, func(f wire.Field) error {
if f.Number == n && f.Type == 1 {
v = math.Float64frombits(binary.LittleEndian.Uint64(f.Value))
}
return nil
})
return v
}
func (s *Service) boxItems(id uint64) ([]uint64, error) {
r, e := s.design.Row("FieldMiniGameSurvivalBoxTable", 2, id)
if e != nil {
return nil, e
}
values, e := list(r, 3)
if e != nil {
return nil, e
}
return values, nil
}
func (s *Service) survivalOffers(a *Run) ([]byte, error) {
if a.SkillCredits == 0 {
return nil, fmt.Errorf("eventplay: no earned skill selection")
}
if len(a.Offers) > 0 {
if a.Rerolls == 0 {
return nil, fmt.Errorf("eventplay: no remaining rerolls")
}
a.Rerolls--
}
a.Offers = nil
rows := append([][]byte(nil), s.design.Tables["FieldMiniGameSkillGroupTable"]...)
sort.Slice(rows, func(i, j int) bool { return num(rows[i], 1) < num(rows[j], 1) })
for _, r := range rows {
id := num(r, 1)
if a.SkillLevels[id] < num(r, 2) {
a.Offers = append(a.Offers, id)
if len(a.Offers) == 3 {
break
}
}
}
out := wire.AppendVarint(nil, 1, a.Rerolls)
for _, id := range a.Offers {
out = wire.AppendVarint(out, 2, id)
}
return out, nil
}
@@ -0,0 +1,60 @@
package eventplay
import (
"bd2server/internal/server/wire"
"testing"
)
func row(fields map[int]uint64) []byte {
var b []byte
for n, v := range fields {
b = wire.AppendVarint(b, n, v)
}
return b
}
func TestSurvivalProgressDerivesLootAndRejectsUnspawnedKills(t *testing.T) {
s, _ := makeService(t)
s.design.Tables["FieldMiniGameCharTable"] = [][]byte{row(map[int]uint64{13: 1, 2: 1})}
s.design.Tables["FieldMiniGameMapTable"] = [][]byte{row(map[int]uint64{2: 1, 3: 1, 4: 1})}
s.design.Tables["FieldMiniGameMonsterTable"] = [][]byte{row(map[int]uint64{2: 1, 5: 1, 12: 1, 6: 1})}
box := row(map[int]uint64{2: 1})
box = wire.AppendVarint(box, 3, 3)
s.design.Tables["FieldMiniGameSurvivalBoxTable"] = [][]byte{box}
s.design.Tables["FieldMiniGameSurvivalItemTable"] = [][]byte{row(map[int]uint64{1: 3, 3: 3, 4: 10})}
s.design.Tables["FieldMiniGameCharLevelTable"] = [][]byte{row(map[int]uint64{1: 1, 2: 1, 3: 10}), row(map[int]uint64{1: 1, 2: 2, 3: 20})}
a := Run{Char: 1, MapGroup: 1, Stage: 1, HP: 10, MaxHP: 10, Started: 1, Killed: map[uint64]uint64{}, Available: map[uint64]uint64{}, Picked: map[uint64]uint64{}, SkillLevels: map[uint64]uint64{}, Level: 1}
req := wire.AppendVarint(nil, 2, 10)
kill := row(map[int]uint64{1: 1, 2: 1})
req = wire.AppendBytes(req, 6, kill)
item := row(map[int]uint64{1: 3, 2: 1})
req = wire.AppendBytes(req, 5, item)
req = wire.AppendVarint(req, 3, 999)
exp, coin, e := s.survivalProgress(req, &a)
if e != nil || exp != 10 || coin != 0 || a.SkillCredits != 1 {
t.Fatalf("exp%d coin%d credit%d err%v", exp, coin, a.SkillCredits, e)
}
if _, _, e = s.survivalProgress(req, &a); e == nil {
t.Fatal("kill exceeded spawn accepted")
}
}
func TestSurvivalSkillsRequireEarnedOfferAndRerolls(t *testing.T) {
s, _ := makeService(t)
s.design.Tables["FieldMiniGameSkillGroupTable"] = [][]byte{row(map[int]uint64{1: 1, 2: 3}), row(map[int]uint64{1: 2, 2: 3})}
a := Run{SkillCredits: 1, Rerolls: 1, SkillLevels: map[uint64]uint64{}}
if _, e := s.survivalOffers(&a); e != nil {
t.Fatal(e)
}
if len(a.Offers) != 2 {
t.Fatal("offer missing")
}
if _, e := s.survivalOffers(&a); e != nil || a.Rerolls != 0 {
t.Fatal("reroll not consumed")
}
if _, e := s.survivalOffers(&a); e == nil {
t.Fatal("infinite reroll")
}
a.SkillCredits = 0
if _, e := s.survivalOffers(&a); e == nil {
t.Fatal("unearned skill selection")
}
}
+66
View File
@@ -0,0 +1,66 @@
package eventplay
import (
"bd2server/internal/server/gamedata"
"fmt"
"strconv"
)
func (s *Service) upgrade(path string, req []byte, uid uint64, next *snapshot, key string) ([]byte, error) {
prefix := fmt.Sprintf("%d:", uid)
var costs, rewards []gamedata.Reward
if path == "/MiniGameSurvivalCharUpgrade" {
id, target := num(req, 2), num(req, 3)
k := prefix + strconv.FormatUint(id, 10)
if target == 0 || target != next.Upgrades[k]+1 {
return nil, fmt.Errorf("eventplay: upgrade level is not next")
}
var row []byte
for _, r := range s.design.Rows("FieldMiniGameUpgradeTable", 1, id) {
if num(r, 2) == target {
row = r
break
}
}
if row == nil {
return nil, fmt.Errorf("eventplay: upgrade design missing")
}
costs = []gamedata.Reward{{Type: 43, Count: num(row, 3)}}
if _, e := s.economy.Apply("eventplay:"+key, costs, nil); e != nil {
return nil, e
}
next.Upgrades[k] = target
return nil, nil
}
for _, r := range s.design.Tables["FieldMiniGameUpgradeTable"] {
id, level := num(r, 1), num(r, 2)
if next.Upgrades[prefix+strconv.FormatUint(id, 10)] >= level {
rewards = append(rewards, gamedata.Reward{Type: 43, Count: num(r, 3)})
}
}
bundle, e := s.economy.Apply("eventplay:"+key, nil, rewards)
if e != nil {
return nil, e
}
for k := range next.Upgrades {
if len(k) >= len(prefix) && k[:len(prefix)] == prefix {
delete(next.Upgrades, k)
}
}
return bundle, nil
}
func rewardDifference(old, next []gamedata.BattleReward) []gamedata.BattleReward {
paid := map[[2]uint64]uint64{}
for _, r := range old {
paid[[2]uint64{r.Type, r.ID}] += r.Count
}
var out []gamedata.BattleReward
for _, r := range next {
n := paid[[2]uint64{r.Type, r.ID}]
if r.Count > n {
r.Count -= n
out = append(out, r)
}
}
return out
}
+164
View File
@@ -0,0 +1,164 @@
package events
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"encoding/json"
"fmt"
"math"
"time"
)
type economyAPClock struct {
Next int64 `json:"next"`
}
func (e *Economy) AttachAPRefresh(caps map[uint64]uint64, design gamedata.HuntingAPDesign) error {
e.mu.Lock()
defer e.mu.Unlock()
for typ, max := range caps {
if (typ != 30 && typ != 32) || max == 0 || max > math.MaxInt32 {
return fmt.Errorf("events: invalid free AP refresh")
}
}
e.apCaps = caps
e.resetSeconds = design.ResetSeconds
e.now = time.Now
return nil
}
func (e *Economy) refreshAP(s *economySnapshot) error {
if len(e.apCaps) == 0 {
return nil
}
now := e.now().UnixMilli()
offset := (e.resetSeconds - 9*3600) * 1000
next := ((now-offset)/86400000+1)*86400000 + offset
b, err := e.store.Load("eventapclock")
if err != nil {
return err
}
clock := economyAPClock{Next: next}
reset := b == nil
if b != nil {
if err = stateio.RequireExactJSONObject(b, "next"); err != nil {
return err
}
if err = json.Unmarshal(b, &clock); err != nil {
return err
}
reset = now >= clock.Next
}
if !reset {
return nil
}
for typ, max := range e.apCaps {
s.Balances[typ] = max
}
clock.Next = next
raw, err := json.Marshal(clock)
if err != nil {
return err
}
if err = e.store.Save("eventapclock", raw); err != nil {
return err
}
raw, err = json.Marshal(s)
if err != nil {
return err
}
return e.store.Save("event_economy", raw)
}
func (e *Economy) CanApply(costs []gamedata.Reward) error {
e.mu.Lock()
defer e.mu.Unlock()
s, err := e.load()
if err != nil {
return err
}
if err = e.refreshAP(&s); err != nil {
return err
}
var wallet, ap []gamedata.Reward
var items []player.Item
totals := map[[2]uint64]uint64{}
for _, r := range costs {
k := [2]uint64{r.Type, r.ID}
if r.Count == 0 || r.Count > math.MaxInt32 || totals[k] > math.MaxInt32-r.Count {
return fmt.Errorf("events: invalid cost")
}
totals[k] += r.Count
}
for k, n := range totals {
r := gamedata.Reward{Type: k[0], ID: k[1], Count: n}
switch {
case walletCurrency(r.Type):
wallet = append(wallet, r)
case apCurrency(r.Type):
ap = append(ap, r)
case extraCurrency(r.Type):
if r.ID != 0 || s.Balances[r.Type] < n {
return fmt.Errorf("events: insufficient currency")
}
case inventoryType(r.Type):
selected, err := e.items.SelectMutable(r.Type, r.ID, n)
if err != nil {
return err
}
items = append(items, selected...)
default:
return fmt.Errorf("events: unsupported cost type %d", r.Type)
}
}
if err = e.wallet.CanExchange(wallet, nil); err != nil {
return err
}
if len(ap) > 0 {
if e.hunting == nil {
return fmt.Errorf("events: AP unavailable")
}
if err = e.hunting.CanExchangeAP(ap, nil); err != nil {
return err
}
}
if len(items) > 0 {
return e.items.CanConsume(items)
}
return nil
}
func (e *Economy) ChargeInfo() ([]byte, error) {
e.mu.Lock()
defer e.mu.Unlock()
s, err := e.load()
if err != nil {
return nil, err
}
if err = e.refreshAP(&s); err != nil {
return nil, err
}
raw, err := e.store.Load("eventapclock")
if err != nil {
return nil, err
}
if raw == nil {
return nil, nil
}
var clock economyAPClock
if err = json.Unmarshal(raw, &clock); err != nil {
return nil, err
}
var out []byte
for _, typ := range []uint64{30, 32} {
if _, ok := e.apCaps[typ]; !ok {
continue
}
info := wire.AppendVarint(nil, 1, uint64(clock.Next-86400000))
item := wire.AppendVarint(nil, 3, typ)
item = wire.AppendVarint(item, 4, s.Balances[typ])
info = wire.AppendBytes(info, 2, item)
out = wire.AppendBytes(out, 1, info)
}
return out, nil
}
+86
View File
@@ -0,0 +1,86 @@
package events
import (
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"encoding/json"
"fmt"
"math"
"strconv"
"sync"
)
type BoxService struct {
mu sync.Mutex
items *player.Inventory
economy *Economy
store stateio.Store
}
type boxReceipt struct{ Request, Response []byte }
func OpenBoxes(store stateio.Store, items *player.Inventory, economy *Economy) (*BoxService, error) {
if store == nil || items == nil || economy == nil {
return nil, fmt.Errorf("events: invalid box runtime")
}
return &BoxService{store: store, items: items, economy: economy}, nil
}
func (s *BoxService) Handle(path string, req []byte) (int, []byte, bool, error) {
return s.HandleSession(path, req, "local")
}
func (s *BoxService) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) {
if path != "/UseRandomBox" {
return 0, nil, false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
seq, _, e := wire.Varint(req, 1)
index, _, e2 := wire.Varint(req, 2)
count, _, e3 := wire.Varint(req, 3)
if e != nil || e2 != nil || e3 != nil || seq == 0 || index == 0 || count == 0 || count > 1000 || seq > math.MaxInt32 || index > math.MaxInt64 {
return 143, nil, true, fmt.Errorf("events: invalid box request")
}
key := session + ":" + strconv.FormatUint(seq, 10)
receipts := map[string]boxReceipt{}
raw, e := s.store.Load("eventboxes")
if e != nil {
return 143, nil, true, e
}
if raw != nil {
if e = json.Unmarshal(raw, &receipts); e != nil {
return 143, nil, true, e
}
}
if r, ok := receipts[key]; ok {
if !bytes.Equal(req, r.Request) {
return 143, nil, true, fmt.Errorf("events: box sequence conflict")
}
return 143, r.Response, true, nil
}
var box player.Item
for _, item := range s.items.All() {
if item.InvenIndex == index {
box = item
break
}
}
if box.Type != 9 || box.Count < count {
return 143, nil, true, fmt.Errorf("events: box unavailable")
}
box.Count = count
bundle, e := s.economy.OpenBox("usebox:"+key, box)
if e != nil {
return 143, nil, true, e
}
out := wire.AppendBytes(nil, 1, bundle)
receipts[key] = boxReceipt{append([]byte(nil), req...), out}
raw, e = json.Marshal(receipts)
if e != nil {
return 143, nil, true, e
}
if e = s.store.Save("eventboxes", raw); e != nil {
return 143, nil, true, e
}
return 143, out, true, nil
}
+435
View File
@@ -0,0 +1,435 @@
package events
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"sync"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type HuntingAP interface {
CanExchangeAP(costs, rewards []gamedata.Reward) error
ExchangeAPOnce(identity string, costs, rewards []gamedata.Reward) error
}
type rewardResolver interface {
Resolve([]gamedata.BattleReward) ([]gamedata.BattleReward, error)
}
type equipmentRoller interface {
RollOptions(uint64) ([]gamedata.EquipmentOptionChoice, []gamedata.EquipmentOptionChoice, *gamedata.EquipmentOptionChoice, error)
}
type economyReceipt struct{ Digest, Definition, Bundle []byte }
type economySnapshot struct {
Balances map[uint64]uint64 `json:"balances"`
Receipts map[string]economyReceipt `json:"receipts"`
}
// Economy dispatches verified static rewards to their owning domains. It is
// called inside the same account transaction as the gameplay operation.
type Economy struct {
ownedDesign map[uint64]map[uint64]bool
mu sync.Mutex
store stateio.Store
items *player.Inventory
wallet *player.Wallet
collection *player.CollectionStore
equipment *player.EquipmentInventory
costumes player.CostumeDesignSource
options equipmentRoller
graph rewardResolver
hunting HuntingAP
prestige map[uint64]uint64
apCaps map[uint64]uint64
resetSeconds int64
now func() time.Time
initial map[uint64]uint64
}
func NewEconomy(store stateio.Store, items *player.Inventory, wallet *player.Wallet, collection *player.CollectionStore, equipment *player.EquipmentInventory, costumes player.CostumeDesignSource, options equipmentRoller, graph rewardResolver, initial map[uint64]uint64) (*Economy, error) {
if store == nil || items == nil || wallet == nil || collection == nil || equipment == nil || costumes == nil || options == nil || graph == nil {
return nil, fmt.Errorf("events: invalid economy configuration")
}
e := &Economy{store: store, items: items, wallet: wallet, collection: collection, equipment: equipment, costumes: costumes, options: options, graph: graph, initial: map[uint64]uint64{}}
for t, n := range initial {
if _, ok := AdditionalCurrencyFields[t]; !ok || n > math.MaxInt32 {
return nil, fmt.Errorf("events: invalid initial currency %d", t)
}
e.initial[t] = n
}
_, err := e.load()
return e, err
}
// AdditionalCurrencyFields maps EElementType to its UserDBInfo field. These
// balances use a gameplay entry, leaving the frozen wallet schema unchanged.
var AdditionalCurrencyFields = map[uint64]int{15: 16, 16: 17, 18: 18, 24: 22, 30: 34, 31: 35, 32: 36, 33: 37, 43: 48, 44: 54, 60: 60}
func (e *Economy) AttachHuntingAP(h HuntingAP) { e.hunting = h }
func (e *Economy) AdditionalCurrencies() (map[int]uint64, error) {
e.mu.Lock()
defer e.mu.Unlock()
s, err := e.load()
if err != nil {
return nil, err
}
if err = e.refreshAP(&s); err != nil {
return nil, err
}
out := map[int]uint64{}
for t, f := range AdditionalCurrencyFields {
out[f] = s.Balances[t]
}
return out, nil
}
func (e *Economy) load() (economySnapshot, error) {
s := economySnapshot{Balances: map[uint64]uint64{}, Receipts: map[string]economyReceipt{}}
for t, n := range e.initial {
s.Balances[t] = n
}
b, err := e.store.Load("event_economy")
if err != nil || b == nil {
return s, err
}
if err = stateio.RequireExactJSONObject(b, "balances", "receipts"); err != nil {
return s, err
}
if err = json.Unmarshal(b, &s); err != nil {
return s, err
}
if s.Balances == nil || s.Receipts == nil {
return s, fmt.Errorf("events: invalid saved economy")
}
for t, n := range s.Balances {
if _, ok := AdditionalCurrencyFields[t]; !ok || n > math.MaxInt32 {
return s, fmt.Errorf("events: invalid saved currency %d", t)
}
}
for id, r := range s.Receipts {
if id == "" || len(r.Digest) != sha256.Size {
return s, fmt.Errorf("events: invalid economy receipt")
}
}
return s, nil
}
func walletCurrency(t uint64) bool {
switch t {
case 2, 3, 4, 12, 20, 22, 68:
return true
}
return false
}
func extraCurrency(t uint64) bool { _, ok := AdditionalCurrencyFields[t]; return ok }
func apCurrency(t uint64) bool { return t == 21 || t == 23 }
func inventoryType(t uint64) bool {
switch t {
case 5, 7, 8, 9, 13, 14, 17, 19, 25, 26, 27, 29, 34, 45, 46, 47, 49:
return true
}
return false
}
func (e *Economy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards})
d := sha256.Sum256(definition)
e.mu.Lock()
s, err := e.load()
if err != nil {
e.mu.Unlock()
return nil, err
}
if r, ok := s.Receipts[identity]; ok {
e.mu.Unlock()
if !bytes.Equal(r.Definition, d[:]) {
return nil, fmt.Errorf("events: reward identity reused with different definition")
}
return append([]byte(nil), r.Bundle...), nil
}
e.mu.Unlock()
var selected []player.Item
// Merge repeated definitions before selecting stacks so the same quantity
// cannot be selected twice from a single inventory entry.
totals := map[[2]uint64]uint64{}
for _, r := range costs {
k := [2]uint64{r.Type, r.ID}
if r.Count == 0 || r.Count > math.MaxInt64 || totals[k] > math.MaxInt64-r.Count {
return nil, fmt.Errorf("events: invalid cost")
}
totals[k] += r.Count
}
keys := make([][2]uint64, 0, len(totals))
for k := range totals {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i][0] != keys[j][0] {
return keys[i][0] < keys[j][0]
}
return keys[i][1] < keys[j][1]
})
for _, k := range keys {
n := totals[k]
if walletCurrency(k[0]) || extraCurrency(k[0]) || apCurrency(k[0]) {
if k[1] != 0 {
return nil, fmt.Errorf("events: currency has item id")
}
selected = append(selected, player.Item{Type: k[0], Count: n})
continue
}
items, err := e.items.SelectMutable(k[0], k[1], n)
if err != nil {
return nil, err
}
selected = append(selected, items...)
}
var rs []gamedata.BattleReward
for _, r := range rewards {
rs = append(rs, gamedata.BattleReward(r))
}
return e.consumeAndGrant(identity, selected, rs, d[:], false)
}
func (e *Economy) ConsumeAndGrant(identity string, consumed []player.Item, rewards []gamedata.BattleReward) ([]byte, error) {
return e.consumeAndGrant(identity, consumed, rewards, nil, false)
}
func (e *Economy) consumeAndGrant(identity string, consumed []player.Item, rewards []gamedata.BattleReward, definition []byte, openBox bool) ([]byte, error) {
e.mu.Lock()
defer e.mu.Unlock()
if identity == "" {
return nil, fmt.Errorf("events: empty reward identity")
}
s, err := e.load()
if err != nil {
return nil, err
}
raw, err := json.Marshal(struct {
Costs []player.Item
Rewards []gamedata.BattleReward
}{consumed, rewards})
if err != nil {
return nil, err
}
digest := sha256.Sum256(raw)
if receipt, ok := s.Receipts[identity]; ok {
if !bytes.Equal(receipt.Digest, digest[:]) {
return nil, fmt.Errorf("events: reward identity reused with different contents")
}
return append([]byte(nil), receipt.Bundle...), nil
}
if err = e.refreshAP(&s); err != nil {
return nil, err
}
var itemCosts []player.Item
var walletCosts, apCosts []gamedata.Reward
for _, c := range consumed {
if c.Count == 0 || c.Count > math.MaxInt32 {
return nil, fmt.Errorf("events: invalid consumption count")
}
r := gamedata.Reward{Type: c.Type, ID: c.ID, Count: c.Count}
switch {
case walletCurrency(c.Type):
if c.ID != 0 || c.InvenIndex != 0 {
return nil, fmt.Errorf("events: invalid currency cost")
}
walletCosts = append(walletCosts, r)
case apCurrency(c.Type):
if c.ID != 0 || c.InvenIndex != 0 {
return nil, fmt.Errorf("events: invalid hunting AP cost")
}
apCosts = append(apCosts, r)
case extraCurrency(c.Type):
if c.ID != 0 || c.InvenIndex != 0 || s.Balances[c.Type] < c.Count {
return nil, fmt.Errorf("events: insufficient currency %d", c.Type)
}
s.Balances[c.Type] -= c.Count
default:
if !inventoryType(c.Type) {
return nil, fmt.Errorf("events: unsupported consumed item type %d", c.Type)
}
itemCosts = append(itemCosts, c)
}
}
if len(itemCosts) > 0 {
if err = e.items.CanConsume(itemCosts); err != nil {
return nil, err
}
}
var expanded []gamedata.BattleReward
if openBox {
expanded, err = e.graph.Resolve(rewards)
} else {
expanded, err = e.resolveGranted(rewards)
}
if err != nil {
return nil, err
}
var walletRewards, apRewards []gamedata.Reward
var itemRewards []gamedata.BattleReward
var costumes []uint64
var equips []player.Equipment
var bundle []byte
for _, r := range expanded {
if r.Count == 0 || r.Count > math.MaxInt32 {
return nil, fmt.Errorf("events: invalid reward count")
}
switch {
case walletCurrency(r.Type):
if r.ID != 0 {
return nil, fmt.Errorf("events: currency reward has item id")
}
walletRewards = append(walletRewards, gamedata.Reward(r))
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, Count: r.Count}))
case apCurrency(r.Type):
if r.ID != 0 {
return nil, fmt.Errorf("events: invalid AP reward")
}
apRewards = append(apRewards, gamedata.Reward(r))
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, Count: r.Count}))
case extraCurrency(r.Type):
if r.ID != 0 || s.Balances[r.Type] > math.MaxInt32-r.Count {
return nil, fmt.Errorf("events: currency overflow")
}
s.Balances[r.Type] += r.Count
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, Count: r.Count}))
case r.Type == 11:
if _, ok := e.costumes.Character(r.ID); !ok || r.Count > 1000 {
return nil, fmt.Errorf("events: unknown or excessive costume reward %d", r.ID)
}
for n := uint64(0); n < r.Count; n++ {
costumes = append(costumes, r.ID)
}
case r.Type == 10:
if r.Count > 1000 {
return nil, fmt.Errorf("events: excessive equipment reward")
}
for n := uint64(0); n < r.Count; n++ {
main, sub, private, rollErr := e.options.RollOptions(r.ID)
if rollErr != nil {
return nil, rollErr
}
entry := player.Equipment{ID: r.ID, Rank: []uint64{0, 0, 0}}
for _, o := range main {
entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: o.GroupID, ID: o.ID})
}
for _, o := range sub {
entry.SubOption = append(entry.SubOption, player.EquipmentOption{GroupID: o.GroupID, ID: o.ID})
}
if private != nil {
entry.PrivateOption = &player.EquipmentOption{GroupID: private.GroupID, ID: private.ID}
}
equips = append(equips, entry)
}
case inventoryType(r.Type):
if r.Type == 47 || r.Type == 49 {
if !e.ownedDesign[r.Type][r.ID] {
return nil, fmt.Errorf("events: unknown owned item design %d:%d", r.Type, r.ID)
}
}
if r.Type == 45 {
if _, ok := e.prestige[r.ID]; !ok {
return nil, fmt.Errorf("events: unknown prestige skin %d", r.ID)
}
}
if r.ID == 0 {
return nil, fmt.Errorf("events: zero inventory reward id")
}
itemRewards = append(itemRewards, r)
default:
return nil, fmt.Errorf("events: unsupported reward type %d", r.Type)
}
}
if err = e.wallet.CanExchange(walletCosts, walletRewards); err != nil {
return nil, err
}
if len(apCosts)+len(apRewards) > 0 {
if e.hunting == nil {
return nil, fmt.Errorf("events: hunting AP runtime unavailable")
}
if err = e.hunting.CanExchangeAP(apCosts, apRewards); err != nil {
return nil, err
}
}
// Validation and random generation finish before the first account write.
if len(itemCosts) > 0 {
if err = e.items.Consume(itemCosts); err != nil {
return nil, err
}
}
if err = e.wallet.ExchangeOnce(identity+":wallet", walletCosts, walletRewards); err != nil {
return nil, err
}
if len(apCosts)+len(apRewards) > 0 {
if err = e.hunting.ExchangeAPOnce(identity+":ap", apCosts, apRewards); err != nil {
return nil, err
}
}
items, err := e.items.GrantOnce(identity+":items", itemRewards)
if err != nil {
return nil, err
}
for _, item := range items {
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item))
bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{Type: item.Type, ID: item.ID, Count: item.Count}))
}
if len(costumes) > 0 {
grant, err := e.collection.GrantCostumes(identity+":costumes", costumes, e.costumes)
if err != nil {
return nil, err
}
var overflow uint64
for _, x := range grant.Exchanges {
if x.ExchangeItemType != 20 || x.ExchangeCount > math.MaxInt64-overflow {
return nil, fmt.Errorf("events: invalid costume exchange")
}
overflow += x.ExchangeCount
}
if overflow > 0 {
if _, err = e.wallet.GrantMileageOnce(identity+":overflow", overflow); err != nil {
return nil, err
}
}
bundle = append(bundle, player.CollectionRewardBundle(e.collection, grant)...)
}
for i, entry := range equips {
saved, err := e.equipment.GrantGeneratedOnce(identity+":equipment:"+strconv.Itoa(i), entry)
if err != nil {
return nil, err
}
bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(saved))
bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{Type: 10, ID: saved.ID, Count: 1}))
}
s.Receipts[identity] = economyReceipt{Digest: digest[:], Definition: definition, Bundle: bundle}
data, err := json.Marshal(s)
if err != nil {
return nil, err
}
if err = e.store.Save("event_economy", data); err != nil {
return nil, err
}
return bundle, nil
}
func (e *Economy) resolveGranted(rewards []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
if g, ok := e.graph.(interface {
ResolveGranted([]gamedata.BattleReward) ([]gamedata.BattleReward, error)
}); ok {
return g.ResolveGranted(rewards)
}
return e.graph.Resolve(rewards)
}
func (e *Economy) OpenBox(identity string, box player.Item) ([]byte, error) {
return e.consumeAndGrant(identity, []player.Item{box}, []gamedata.BattleReward{{Type: 9, ID: box.ID, Count: box.Count}}, nil, true)
}
+270
View File
@@ -0,0 +1,270 @@
package events
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"fmt"
"path/filepath"
"testing"
"time"
)
type economyCostumes struct{}
func (economyCostumes) Character(id uint64) (gamedata.CharacterDesign, bool) {
return gamedata.CharacterDesign{ID: 6060, HP: 100, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 200}, id == 60601
}
type economyOptions struct{}
func (economyOptions) RollOptions(uint64) ([]gamedata.EquipmentOptionChoice, []gamedata.EquipmentOptionChoice, *gamedata.EquipmentOptionChoice, error) {
return nil, nil, nil, nil
}
type economyGraph struct{ calls int }
func (g *economyGraph) Resolve(rs []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
g.calls++
var out []gamedata.BattleReward
for _, r := range rs {
if r.Type == 9 {
out = append(out, gamedata.BattleReward{Type: 4, Count: r.Count * 10})
} else {
out = append(out, r)
}
}
return out, nil
}
func economyFixture(t *testing.T, store stateio.Store, g *economyGraph) (*Economy, *player.Inventory, *player.Wallet) {
t.Helper()
items, e := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
if e != nil {
t.Fatal(e)
}
wallet, e := player.OpenWallet(store, player.Currency{Gold: 5})
if e != nil {
t.Fatal(e)
}
collection, e := player.OpenCollectionStore(store, nil)
if e != nil {
t.Fatal(e)
}
equips, e := player.OpenEquipmentInventory(store)
if e != nil {
t.Fatal(e)
}
for _, f := range []func() error{items.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted, equips.EnsurePersisted} {
if e = f(); e != nil {
t.Fatal(e)
}
}
eco, e := NewEconomy(store, items, wallet, collection, equips, economyCostumes{}, economyOptions{}, g, map[uint64]uint64{15: 3})
if e != nil {
t.Fatal(e)
}
return eco, items, wallet
}
func TestEconomyReplayBeforeCostSelectionAndRandom(t *testing.T) {
g := &economyGraph{}
eco, _, wallet := economyFixture(t, stateio.NewMemory(), g)
cost := []gamedata.Reward{{Type: 4, Count: 5}}
rewards := []gamedata.Reward{{Type: 8, ID: 1000, Count: 2}}
a, e := eco.Apply("request", cost, rewards)
if e != nil {
t.Fatal(e)
}
b, e := eco.Apply("request", cost, rewards)
if e != nil || !bytes.Equal(a, b) || g.calls != 1 || wallet.Snapshot().Gold != 0 {
t.Fatalf("retry cost/RNG %v calls %d", e, g.calls)
}
if _, e = eco.Apply("request", cost, nil); e == nil {
t.Fatal("identity definition conflict accepted")
}
}
func TestEconomyRejectsUnknownCostAndCurrencyID(t *testing.T) {
eco, _, wallet := economyFixture(t, stateio.NewMemory(), &economyGraph{})
if _, e := eco.ConsumeAndGrant("unknown", []player.Item{{Type: 999, ID: 1, InvenIndex: 1, Count: 1}}, nil); e == nil {
t.Fatal("unknown cost accepted")
}
if _, e := eco.Apply("badcurrency", nil, []gamedata.Reward{{Type: 4, ID: 3, Count: 1}}); e == nil {
t.Fatal("currency ID accepted")
}
if wallet.Snapshot().Gold != 5 {
t.Fatal("validation mutated wallet")
}
}
func TestEconomySQLiteAtomicRollbackAndRetry(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, e := accountstate.Open(path)
if e != nil {
t.Fatal(e)
}
g := &economyGraph{}
eco, _, wallet := economyFixture(t, repo, g)
op, e := repo.BeginOperation()
if e != nil {
t.Fatal(e)
}
cost := []gamedata.Reward{{Type: 4, Count: 5}}
rewards := []gamedata.Reward{{Type: 8, ID: 1000, Count: 2}, {Type: 15, Count: 2}}
if _, e = eco.Apply("atomic", cost, rewards); e != nil {
t.Fatal(e)
}
if wallet.Snapshot().Gold != 0 {
t.Fatal("operation not applied")
}
_ = op.Rollback()
repo.Close()
repo, e = accountstate.Open(path)
if e != nil {
t.Fatal(e)
}
defer repo.Close()
eco, items, wallet := economyFixture(t, repo, &economyGraph{})
if wallet.Snapshot().Gold != 5 || len(items.All()) != 0 {
t.Fatal("rollback kept rewards/cost")
}
currencies, e := eco.AdditionalCurrencies()
if e != nil || currencies[16] != 3 {
t.Fatalf("extra currency survived rollback %v %v", currencies, e)
}
op, e = repo.BeginOperation()
if e != nil {
t.Fatal(e)
}
if _, e = eco.Apply("atomic", cost, rewards); e != nil {
t.Fatal(e)
}
if e = op.Commit(); e != nil {
t.Fatal(e)
}
if wallet.Snapshot().Gold != 0 || len(items.All()) != 1 {
t.Fatal("retry failed")
}
}
func TestEconomyOptionalCurrencyOverflow(t *testing.T) {
eco, _, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{})
if _, e := eco.Apply("overflow", nil, []gamedata.Reward{{Type: 15, Count: 2147483647}}); e == nil {
t.Fatal("optional currency overflow accepted")
}
currencies, e := eco.AdditionalCurrencies()
if e != nil || currencies[16] != 3 {
t.Fatal(fmt.Sprint(currencies, e))
}
}
func TestEconomyDailyFreeAPOnly(t *testing.T) {
eco, _, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{})
if e := eco.AttachAPRefresh(map[uint64]uint64{30: 5, 32: 60}, gamedata.HuntingAPDesign{ResetSeconds: 32400}); e != nil {
t.Fatal(e)
}
now := time.Date(2026, 10, 4, 23, 59, 0, 0, time.UTC)
eco.now = func() time.Time { return now }
if e := eco.CanApply([]gamedata.Reward{{Type: 30, Count: 5}}); e != nil {
t.Fatal(e)
}
if _, e := eco.Apply("day1", []gamedata.Reward{{Type: 30, Count: 5}}, nil); e != nil {
t.Fatal(e)
}
if e := eco.CanApply([]gamedata.Reward{{Type: 30, Count: 1}}); e == nil {
t.Fatal("CanApply ignored spent AP")
}
now = now.Add(2 * time.Minute)
if e := eco.CanApply([]gamedata.Reward{{Type: 30, Count: 5}}); e != nil {
t.Fatal(e)
}
currencies, e := eco.AdditionalCurrencies()
if e != nil || currencies[16] != 3 || currencies[34] != 5 || currencies[36] != 60 {
t.Fatalf("refresh changed wrong currency %+v %v", currencies, e)
}
if _, e = eco.ChargeInfo(); e != nil {
t.Fatal(e)
}
}
func (g *economyGraph) ResolveGranted(rs []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
g.calls++
return rs, nil
}
func TestEconomyBoxPreservedUntilExplicitUse(t *testing.T) {
store := stateio.NewMemory()
g := &economyGraph{}
eco, items, wallet := economyFixture(t, store, g)
if _, e := eco.Apply("grantbox", nil, []gamedata.Reward{{Type: 9, ID: 100, Count: 2}}); e != nil {
t.Fatal(e)
}
owned := items.All()
if len(owned) != 1 || owned[0].Type != 9 || wallet.Snapshot().Gold != 5 {
t.Fatal("reward auto-opened manual box")
}
boxes, e := OpenBoxes(store, items, eco)
if e != nil {
t.Fatal(e)
}
req := wire.AppendVarint(nil, 1, 1)
req = wire.AppendVarint(req, 2, owned[0].InvenIndex)
req = wire.AppendVarint(req, 3, 2)
code, a, _, e := boxes.HandleSession("/UseRandomBox", req, "session")
if e != nil || code != 143 || wallet.Snapshot().Gold != 25 || len(items.All()) != 0 {
t.Fatalf("openbox %d %v gold %d", code, e, wallet.Snapshot().Gold)
}
_, b, _, e := boxes.HandleSession("/UseRandomBox", req, "session")
if e != nil || !bytes.Equal(a, b) || g.calls != 2 {
t.Fatalf("box retry rerolled %v calls%d", e, g.calls)
}
}
func TestPrestigeSkinRewardAndSpecialQuery(t *testing.T) {
eco, _, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{})
eco.AttachPrestigeSkins(map[uint64]uint64{9901: 60601})
if _, e := eco.Apply("skin", nil, []gamedata.Reward{{Type: 45, ID: 9901, Count: 1}}); e != nil {
t.Fatal(e)
}
code, out, ok, e := eco.PrestigeSkinInfo("/PrestigeSkinInfo", wire.AppendVarint(nil, 1, 1))
entry, found, _ := wire.Bytes(out, 1)
costume, _, _ := wire.Varint(entry, 1)
design, _, _ := wire.Varint(entry, 2)
if e != nil || !ok || code != 425 || !found || costume != 60601 || design != 9901 {
t.Fatalf("skin wire %x %v", out, e)
}
}
func TestCurrencyEnumProjectionMiniGameAndDeco(t *testing.T) {
eco, _, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{})
if _, e := eco.Apply("coins", nil, []gamedata.Reward{{Type: 43, Count: 7}, {Type: 44, Count: 11}}); e != nil {
t.Fatal(e)
}
fields, e := eco.AdditionalCurrencies()
if e != nil || fields[48] != 7 || fields[54] != 11 {
t.Fatalf("enum projection %+v %v", fields, e)
}
}
func TestOwnedEventItemsAndColosseumCoin(t *testing.T) {
eco, items, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{})
eco.AttachOwnedItemDesign(map[uint64]map[uint64]bool{47: {100: true}, 49: {200: true}})
if _, e := eco.Apply("eventowned", nil, []gamedata.Reward{{Type: 47, ID: 100, Count: 1}, {Type: 49, ID: 200, Count: 1}, {Type: 60, Count: 10}}); e != nil {
t.Fatal(e)
}
if len(items.All()) != 2 {
t.Fatal("owned event rewards absent")
}
currencies, e := eco.AdditionalCurrencies()
if e != nil || currencies[60] != 10 {
t.Fatal("colosseum coin missing")
}
code, out, _, e := eco.OwnedItemInfo("/AvatarInfo", wire.AppendVarint(nil, 1, 1))
item, found, _ := wire.Bytes(out, 2)
typ, _, _ := wire.Varint(item, 3)
id, _, _ := wire.Varint(item, 2)
if e != nil || code != 467 || !found || typ != 49 || id != 200 {
t.Fatalf("avatar query%x %v", out, e)
}
if _, e = eco.Apply("badavatar", nil, []gamedata.Reward{{Type: 49, ID: 201, Count: 1}}); e == nil {
t.Fatal("unknown avatar accepted")
}
}
+28
View File
@@ -0,0 +1,28 @@
package events
import (
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
"fmt"
)
func (e *Economy) AttachOwnedItemDesign(d map[uint64]map[uint64]bool) { e.ownedDesign = d }
// ID card items are loaded through ItemInfo; there is no IdCardInfo ownership
// endpoint. AvatarInfo separately includes its owned ItemDBInfo list.
func (e *Economy) OwnedItemInfo(path string, req []byte) (int, []byte, bool, error) {
if path != "/AvatarInfo" {
return 0, nil, false, nil
}
seq, _, err := wire.Varint(req, 1)
if err != nil || seq == 0 {
return 467, nil, true, fmt.Errorf("events: missing sequence")
}
var out []byte
for _, item := range e.items.All() {
if item.Type == 49 {
out = wire.AppendBytes(out, 2, player.ItemWire(item))
}
}
return 467, out, true, nil
}
+100
View File
@@ -0,0 +1,100 @@
// Package events holds the public event calendar and shared account rewards.
package events
import (
"fmt"
"sync"
"time"
"bd2server/internal/server/wire"
)
type Schedule struct {
UID, Type, ID, SubID uint64
Start, End int64
}
// Resolver separates gameplay from the project's published calendar.
type Resolver interface {
Resolve(uid uint64) (Schedule, error)
List() []Schedule
}
type Registry struct {
mu sync.RWMutex
rows []Schedule
now func() time.Time
}
func NewRegistry() *Registry { return &Registry{now: time.Now} }
// Replace atomically installs an explicitly configured calendar. An empty
// calendar is valid; gameplay code does not invent official event dates.
func (r *Registry) Replace(rows []Schedule) error {
seen := map[string]bool{}
for _, s := range rows {
identity := fmt.Sprintf("uid:%d", s.UID)
if s.UID == 0 {
identity = fmt.Sprintf("public:%d:%d:%d", s.Type, s.ID, s.SubID)
}
if s.ID == 0 || s.Type > 25 || s.Type == 2 || s.End <= s.Start || seen[identity] {
return fmt.Errorf("events: invalid or duplicate schedule %d", s.UID)
}
seen[identity] = true
}
r.mu.Lock()
defer r.mu.Unlock()
r.rows = append([]Schedule(nil), rows...)
return nil
}
func (r *Registry) List() []Schedule {
r.mu.RLock()
defer r.mu.RUnlock()
return append([]Schedule(nil), r.rows...)
}
func (r *Registry) Resolve(uid uint64) (Schedule, error) {
r.mu.RLock()
defer r.mu.RUnlock()
var result Schedule
found := false
for _, s := range r.rows {
if s.UID == uid {
if found {
return Schedule{}, fmt.Errorf("events: ambiguous public schedule %d requires design identity", uid)
}
result, found = s, true
}
}
if found {
return result, nil
}
return Schedule{}, fmt.Errorf("events: unknown schedule %d", uid)
}
func (r *Registry) Handle(path string, req []byte) (int, []byte, bool, error) {
if path != "/EventScheduleInfo" {
return 0, nil, false, nil
}
seq, found, err := wire.Varint(req, 1)
if err != nil || !found || seq == 0 {
return 163, nil, true, fmt.Errorf("events: invalid sequence")
}
var out []byte
for _, s := range r.List() {
b := wire.AppendVarint(nil, 1, s.UID)
b = wire.AppendVarint(b, 2, s.Type)
b = wire.AppendVarint(b, 3, s.ID)
if s.SubID != 0 {
b = wire.AppendVarint(b, 4, s.SubID)
}
b = wire.AppendVarint(b, 5, uint64(s.Start))
b = wire.AppendVarint(b, 6, uint64(s.End))
if now := r.now().UnixMilli(); now >= s.Start && now < s.End {
b = wire.AppendVarint(b, 7, 1)
}
out = wire.AppendBytes(out, 1, b)
}
return 163, out, true, nil
}
+103
View File
@@ -0,0 +1,103 @@
package events
import (
"testing"
"time"
"bd2server/internal/server/wire"
)
func TestRegistryExplicitCalendarAndIsolation(t *testing.T) {
r := NewRegistry()
r.now = func() time.Time { return time.UnixMilli(100) }
if len(r.List()) != 0 {
t.Fatal("empty calendar activated events")
}
rows := []Schedule{{UID: 77, Type: 12, ID: 3, Start: 100, End: 200}}
if err := r.Replace(rows); err != nil {
t.Fatal(err)
}
rows[0].ID = 99
got := r.List()
got[0].ID = 88
s, err := r.Resolve(77)
if err != nil || s.ID != 3 {
t.Fatalf("calendar aliased caller: %+v %v", s, err)
}
code, out, ok, err := r.Handle("/EventScheduleInfo", wire.AppendVarint(nil, 1, 1))
if err != nil || !ok || code != 163 {
t.Fatalf("schedule: %d %t %v", code, ok, err)
}
b, _, err := wire.Bytes(out, 1)
if err != nil {
t.Fatal(err)
}
if active, _, _ := wire.Varint(b, 7); active != 1 {
t.Fatal("start boundary inactive")
}
r.now = func() time.Time { return time.UnixMilli(200) }
_, out, _, err = r.Handle("/EventScheduleInfo", wire.AppendVarint(nil, 1, 2))
if err != nil {
t.Fatal(err)
}
b, _, _ = wire.Bytes(out, 1)
if active, _, _ := wire.Varint(b, 7); active != 0 {
t.Fatal("end boundary active")
}
if _, err = r.Resolve(78); err == nil {
t.Fatal("unknown uid resolved")
}
if err = r.Replace([]Schedule{{UID: 77, Type: 2, ID: 3, Start: 100, End: 200}}); err == nil {
t.Fatal("unknown type accepted")
}
if s, _ = r.Resolve(77); s.ID != 3 {
t.Fatal("failed replacement changed calendar")
}
}
func TestPublicZeroUIDRowsRetainSignedWindowsAndRejectAmbiguousLookup(t *testing.T) {
r := NewRegistry()
r.now = func() time.Time { return time.UnixMilli(100) }
rows := []Schedule{
{Type: 4, ID: 1, Start: -32400000, End: 200},
{Type: 4, ID: 2, Start: -32400000, End: 200},
{UID: 99, Type: 4, ID: 3, Start: 300, End: 400},
}
if err := r.Replace(rows); err != nil {
t.Fatal(err)
}
if _, err := r.Resolve(0); err == nil {
t.Fatal("ambiguous UID 0 lookup succeeded")
}
_, body, _, err := r.Handle("/EventScheduleInfo", wire.AppendVarint(nil, 1, 1))
if err != nil {
t.Fatal(err)
}
count := 0
if err := wire.Walk(body, func(f wire.Field) error {
if f.Number != 1 {
return nil
}
count++
start, _, _ := wire.Varint(f.Value, 5)
active, _, _ := wire.Varint(f.Value, 7)
if count <= 2 && (int64(start) != -32400000 || active != 1) {
t.Fatalf("public start=%d active=%d", int64(start), active)
}
if count == 3 && active != 0 {
t.Fatal("future row activated")
}
return nil
}); err != nil {
t.Fatal(err)
}
if count != 3 {
t.Fatalf("rows=%d", count)
}
if err := r.Replace(append(rows, rows[0])); err == nil {
t.Fatal("duplicate semantic public event accepted")
}
if len(r.List()) != 3 {
t.Fatal("failed replacement changed calendar")
}
}
+11
View File
@@ -0,0 +1,11 @@
package events
// SkinHandler restores skin ownership through the client's dedicated query.
type SkinHandler struct{ Economy *Economy }
func (h SkinHandler) Handle(path string, request []byte) (int, []byte, bool, error) {
if code, response, handled, err := h.Economy.OwnedItemInfo(path, request); handled || err != nil {
return code, response, handled, err
}
return h.Economy.PrestigeSkinInfo(path, request)
}
+34
View File
@@ -0,0 +1,34 @@
package events
import (
"bd2server/internal/server/wire"
"fmt"
)
func (e *Economy) AttachPrestigeSkins(design map[uint64]uint64) { e.prestige = design }
func (e *Economy) PrestigeSkinInfo(path string, req []byte) (int, []byte, bool, error) {
if path != "/PrestigeSkinInfo" {
return 0, nil, false, nil
}
seq, _, err := wire.Varint(req, 1)
if err != nil || seq == 0 {
return 425, nil, true, fmt.Errorf("events: missing sequence")
}
var out []byte
seen := map[uint64]bool{}
for _, item := range e.items.All() {
if item.Type != 45 || seen[item.ID] {
continue
}
costume, ok := e.prestige[item.ID]
if !ok {
return 425, nil, true, fmt.Errorf("events: owned skin missing design")
}
seen[item.ID] = true
b := wire.AppendVarint(nil, 1, costume)
b = wire.AppendVarint(b, 2, item.ID)
b = wire.AppendVarint(b, 4, item.TimeValue)
out = wire.AppendBytes(out, 1, b)
}
return 425, out, true, nil
}
@@ -0,0 +1,81 @@
package eventtasks
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"testing"
)
func TestZeroUIDMissionGroupsKeepIndependentClaimsAfterRestart(t *testing.T) {
s, economy, store := setup(t)
now := s.now().UnixMilli()
rows := []events.Schedule{{Type: 4, ID: 7, Start: now - 1, End: now + 10000}, {Type: 4, ID: 8, Start: now - 1, End: now + 10000}}
if err := s.registry.(*events.Registry).Replace(rows); err != nil {
t.Fatal(err)
}
s.design.MissionGroups[8] = gamedata.EventMissionGroup{ID: 8, Groups: []uint64{9}}
if err := s.RecordEvent(2, 0, 2, nil); err != nil {
t.Fatal(err)
}
claim := func(seq, group uint64) []byte {
b := wire.AppendVarint(req(seq), 3, 2)
b = wire.AppendVarint(b, 4, 9)
b = wire.AppendVarint(b, 5, 10)
return wire.AppendVarint(b, 6, group)
}
if _, _, _, err := s.Handle("/MissionClear", claim(1, 7)); err != nil {
t.Fatal(err)
}
if !s.mission(rows[0], 10).Claimed || s.mission(rows[1], 10).Claimed {
t.Fatal("claim leaked across zero-UID groups")
}
restarted, err := Open(store, s.design, s.registry, economy)
if err != nil {
t.Fatal(err)
}
restarted.now = s.now
if !restarted.mission(rows[0], 10).Claimed || restarted.mission(rows[1], 10).Claimed {
t.Fatal("independent claims lost after restart")
}
if _, _, _, err := restarted.Handle("/MissionClear", claim(2, 8)); err != nil {
t.Fatal(err)
}
if economy.calls != 2 {
t.Fatalf("reward grants=%d", economy.calls)
}
}
func TestInactiveCalendarDoesNotHideActivePassMissions(t *testing.T) {
s, _, _ := setup(t)
now := s.now().UnixMilli()
if err := s.registry.(*events.Registry).Replace([]events.Schedule{
{UID: 2, Type: 4, ID: 7, Start: now - 20, End: now - 10},
{UID: 3, Type: 5, ID: 8, Start: now - 1, End: now + 10000},
{UID: 4, Type: 4, ID: 9, Start: now + 10000, End: now + 20000},
}); err != nil {
t.Fatal(err)
}
if err := s.RecordEvent(2, 0, 1, nil); err != nil {
t.Fatal(err)
}
_, out, _, err := s.Handle("/EventMissionInfo", req(1))
if err != nil {
t.Fatal(err)
}
count := 0
if err := wire.Walk(out, func(f wire.Field) error {
if f.Number == 1 {
count++
if scalar(f.Value, 1) != 7 || scalar(f.Value, 4) != 1 {
t.Fatalf("unexpected mission entry %x", f.Value)
}
}
return nil
}); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("active mission count=%d", count)
}
}
+101
View File
@@ -0,0 +1,101 @@
package eventtasks
import (
"bd2server/internal/server/world"
"encoding/json"
"fmt"
)
func (s *Service) BeginSession(id string) { s.SetSession(id) }
func (s *Service) BeforeDispatch(string, []byte) error {
if s.provider == nil {
return nil
}
var e error
s.before, e = s.provider.Snapshot()
return e
}
func (s *Service) AttachGameplayProvider(p world.GameplayAchievementProvider) { s.provider = p }
func (s *Service) AfterDispatch(path string, request, response []byte) ([]byte, error) {
if s.provider != nil {
after, e := s.provider.Snapshot()
if e != nil {
return nil, e
}
rk := fmt.Sprintf("observer:%s:%s:%d", s.session, path, scalar(request, 1))
s.mu.Lock()
_, seen := s.state.Receipts[rk]
s.mu.Unlock()
if !seen {
for kind, old := range s.before.Items {
current := after.Items[kind]
if current < old {
condition := uint64(12)
if kind[1] == 0 {
condition = 11
}
if e = s.RecordEvent(condition, kind[0], old-current, s.unlocked); e != nil {
return nil, e
}
}
}
for kind, current := range after.Items {
old := s.before.Items[kind]
if current > old {
if e = s.RecordEvent(32, kind[0], current-old, s.unlocked); e != nil {
return nil, e
}
}
}
for idx, current := range after.Equipment {
old, ok := s.before.Equipment[idx]
if ok && current.Level > old.Level {
if e = s.RecordEvent(14, 0, current.Level-old.Level, s.unlocked); e != nil {
return nil, e
}
}
}
for idx, current := range after.Costumes {
old, ok := s.before.Costumes[idx]
if ok && current.Level > old.Level {
if e = s.RecordEvent(104, current.ID, current.Level-old.Level, s.unlocked); e != nil {
return nil, e
}
}
}
s.mu.Lock()
s.state.Receipts[rk] = receipt{Digest: "observer"}
e = s.save()
s.mu.Unlock()
if e != nil {
return nil, e
}
}
}
return s.Notify()
}
func (s *Service) CompleteSingleTargetEvent(condition uint64, unlocked func(uint64, uint64) bool) error {
s.mu.Lock()
defer s.mu.Unlock()
before, _ := json.Marshal(s.state)
for _, v := range s.taskSchedules() {
if !s.active(v) {
continue
}
for _, t := range s.design.Missions {
if t.Type != condition || t.SubType != 0 || t.Target != 1 || len(t.Params) != 0 || !s.availableTask(v, t) {
continue
}
if (t.UnlockPack > 0 || t.UnlockQuest > 0) && (unlocked == nil || !unlocked(t.UnlockPack, t.UnlockQuest)) {
continue
}
s.mission(v, t.ID).Value = 1
}
}
if e := s.save(); e != nil {
_ = json.Unmarshal(before, &s.state)
return e
}
return nil
}
@@ -0,0 +1,55 @@
package eventtasks
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"bytes"
"testing"
)
func TestAttendancePremiumRequiresOwnedTicketAndDoesNotDuplicate(t *testing.T) {
for _, paid := range []bool{false, true} {
s, eco, store := setup(t)
s.design.Attendance[1] = gamedata.EventAttendance{ID: 1, Group: 1, Ticket: 77}
s.design.AttendanceRewards[1] = []gamedata.EventAttendanceReward{{ID: 1, Group: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 100}, Premium: gamedata.Reward{Type: 4, Count: 100}}}
s.AttachAttendancePremium(func(ticket uint64) bool { return paid && ticket == 77 })
if _, _, _, err := s.Handle("/Attendance", req(1)); err != nil {
t.Fatal(err)
}
claim := wire.AppendVarint(req(2), 2, 1)
claim = wire.AppendVarint(claim, 3, 1)
claim = wire.AppendVarint(claim, 4, 1)
code, response, handled, err := s.Handle("/EventReward", claim)
if err != nil || code != 151 || !handled {
t.Fatal(err)
}
bundle, ok, err := wire.Bytes(response, 1001)
if err != nil || !ok || !bytes.Equal(bundle, []byte{10, 0}) {
t.Fatal("actual bundle absent", bundle, err)
}
receipt, ok, err := wire.Bytes(response, 1002)
if err != nil || !ok || string(receipt) != "test:/EventReward:2" {
t.Fatal("durable receipt absent", string(receipt), err)
}
want := 1
if paid {
want = 2
}
if len(eco.rewards) != want {
t.Fatal("premium ticket ignored", paid, eco.rewards)
}
if _, replay, _, err := s.Handle("/EventReward", claim); err != nil || eco.calls != 1 || !bytes.Equal(replay, response) {
t.Fatal("claim replay duplicated", err)
}
reopened, err := Open(store, s.design, s.registry, eco)
if err != nil {
t.Fatal(err)
}
reopened.now = s.now
reopened.SetSession("test")
_, replay, _, err := reopened.Handle("/EventReward", claim)
if err != nil || eco.calls != 1 || !bytes.Equal(replay, response) {
t.Fatal("restart lost reward envelope", err)
}
}
}
+905
View File
@@ -0,0 +1,905 @@
// Package eventtasks owns attendance, scheduled missions and pass progress.
// Calendar policy is supplied by events.Resolver; requests never create dates
// or manufacture progress. The parent request transaction includes all saves.
package eventtasks
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
"bd2server/internal/server/world"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"sync"
"time"
)
type Economy interface {
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
}
type attendance struct {
Group, Count uint64
LastDay string
Obtained map[string]bool
History map[string]bool
}
type mission struct {
Value uint64
Claimed bool
Period string
}
type pass struct {
Exp uint64
Premium bool
Claimed map[string]bool
}
type receipt struct {
Digest string
Code int
Reply []byte
}
type snapshot struct {
Version string
NewbieStep uint64
Attendance map[string]*attendance
Missions map[string]*mission
Passes map[string]*pass
LoginDays map[string]int64
Receipts map[string]receipt
}
type Service struct {
provider world.GameplayAchievementProvider
before world.GameplayAchievementSnapshot
mu sync.Mutex
store stateio.Store
design *gamedata.EventTasksDesign
registry events.Resolver
economy Economy
state snapshot
now func() time.Time
session string
unlocked func(uint64, uint64) bool
authorizeCash func(uint64, uint64) bool
attendancePremium func(uint64) bool
associated func(events.Schedule) uint64
}
func Open(store stateio.Store, d *gamedata.EventTasksDesign, r events.Resolver, e Economy) (*Service, error) {
if store == nil || d == nil || r == nil || e == nil {
return nil, errors.New("eventtasks: missing dependency")
}
s := &Service{store: store, design: d, registry: r, economy: e, now: time.Now}
b, err := store.Load("eventtasks")
if err != nil {
return nil, err
}
if b != nil {
if err = stateio.RequireExactJSONObject(b, "Version", "NewbieStep", "Attendance", "Missions", "Passes", "LoginDays", "Receipts"); err != nil {
return nil, err
}
if err = json.Unmarshal(b, &s.state); err != nil {
return nil, err
}
if s.state.Version != versionconfig.State() || s.state.Attendance == nil || s.state.Missions == nil || s.state.Passes == nil || s.state.LoginDays == nil || s.state.Receipts == nil {
return nil, errors.New("eventtasks: invalid state layout")
}
for _, a := range s.state.Attendance {
if a == nil || a.Obtained == nil || a.History == nil {
return nil, errors.New("eventtasks: invalid attendance")
}
}
for _, m := range s.state.Missions {
if m == nil {
return nil, errors.New("eventtasks: invalid mission")
}
}
for _, p := range s.state.Passes {
if p == nil || p.Claimed == nil {
return nil, errors.New("eventtasks: invalid pass")
}
}
} else {
s.state.Version = versionconfig.State()
}
s.init()
return s, nil
}
func (s *Service) init() {
if s.state.Attendance == nil {
s.state.Attendance = map[string]*attendance{}
}
if s.state.Missions == nil {
s.state.Missions = map[string]*mission{}
}
if s.state.Passes == nil {
s.state.Passes = map[string]*pass{}
}
if s.state.LoginDays == nil {
s.state.LoginDays = map[string]int64{}
}
if s.state.Receipts == nil {
s.state.Receipts = map[string]receipt{}
}
}
func (s *Service) SetSession(v string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = v }
func (s *Service) AttachUnlockResolver(f func(uint64, uint64) bool) { s.unlocked = f }
func (s *Service) AttachCashAuthorization(f func(uint64, uint64) bool) { s.authorizeCash = f }
func (s *Service) AttachAttendancePremium(f func(uint64) bool) { s.attendancePremium = f }
func (s *Service) AttachAssociatedMissionGroup(f func(events.Schedule) uint64) { s.associated = f }
func (s *Service) SetNewbieStep(step uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
if step > s.state.NewbieStep {
s.state.NewbieStep = step
}
return s.save()
}
func (s *Service) NewbieStep() uint64 { s.mu.Lock(); defer s.mu.Unlock(); return s.state.NewbieStep }
func (s *Service) save() error {
b, e := json.Marshal(s.state)
if e != nil {
return e
}
return s.store.Save("eventtasks", b)
}
func scalar(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v }
func key(v ...uint64) string {
var k string
for i, n := range v {
if i > 0 {
k += "/"
}
k += strconv.FormatUint(n, 10)
}
return k
}
// scheduleKey preserves the public UID where it is present. UID zero is a
// protocol default shared by permanent groups, so its local identity also
// includes the event type and design group.
func scheduleKey(v events.Schedule) string {
if v.UID != 0 {
return key(v.UID)
}
return key(0, v.Type, v.ID, v.SubID)
}
func (s *Service) active(v events.Schedule) bool {
n := s.now().UnixMilli()
return n >= v.Start && n < v.End
}
func (s *Service) find(t, id uint64) (events.Schedule, error) {
rows := s.registry.List()
if t == 4 {
rows = s.taskSchedules()
}
for _, v := range rows {
if v.Type == t && v.ID == id && s.active(v) {
return v, nil
}
}
return events.Schedule{}, fmt.Errorf("eventtasks: event %d/%d inactive", t, id)
}
func (s *Service) taskSchedules() []events.Schedule {
rows := s.registry.List()
var out []events.Schedule
seen := map[uint64]bool{}
for _, v := range rows {
if v.Type == 4 {
out = append(out, v)
if s.active(v) {
seen[v.ID] = true
}
}
}
for _, v := range rows {
if v.Type == 5 && s.active(v) {
g := s.design.Passes[v.ID].MissionGroup
if g > 0 && !seen[g] {
v.Type = 4
v.ID = g
out = append(out, v)
seen[g] = true
}
}
}
if s.associated != nil {
for _, v := range rows {
if !s.active(v) {
continue
}
g := s.associated(v)
if g > 0 && !seen[g] {
v.Type = 4
v.ID = g
out = append(out, v)
seen[g] = true
}
}
}
return out
}
func (s *Service) resolve(uid uint64) (events.Schedule, error) {
v, e := s.registry.Resolve(uid)
if e != nil {
return v, e
}
if !s.active(v) {
return v, errors.New("eventtasks: event expired")
}
return v, nil
}
func (s *Service) day() string { return s.now().UTC().Format("2006-01-02") }
func (s *Service) attendance(v events.Schedule) *attendance {
k := scheduleKey(v)
a := s.state.Attendance[k]
if a == nil {
g := s.design.Attendance[v.ID].Group
var first uint64
for _, x := range s.design.AttendanceGroups {
if x.Group == g && (first == 0 || x.ID < first) {
first = x.ID
}
}
if first > 0 {
g = first
}
a = &attendance{Group: g, Obtained: map[string]bool{}, History: map[string]bool{}}
s.state.Attendance[k] = a
}
return a
}
func (s *Service) pass(v events.Schedule) *pass {
k := scheduleKey(v)
p := s.state.Passes[k]
if p == nil {
p = &pass{Claimed: map[string]bool{}}
s.state.Passes[k] = p
}
return p
}
func (s *Service) mission(v events.Schedule, id uint64) *mission {
k := scheduleKey(v) + "/" + key(id)
m := s.state.Missions[k]
if m == nil {
m = &mission{}
s.state.Missions[k] = m
}
g := s.design.MissionGroups[v.ID]
period := ""
if g.Type == 1 {
period = s.day()
}
if g.Type == 2 {
y, w := s.now().UTC().ISOWeek()
period = fmt.Sprintf("%d-%d", y, w)
}
if m.Period != period {
*m = mission{Period: period}
}
return m
}
func (s *Service) availableTask(v events.Schedule, t gamedata.EventTask) bool {
g := s.design.MissionGroups[v.ID]
for i, n := range g.Groups {
if n == t.Group {
if g.Type == 3 && int64(i) > (s.now().UnixMilli()-v.Start)/86400000 {
return false
}
return t.UnlockPack == 0 && t.UnlockQuest == 0 || s.unlocked != nil && s.unlocked(t.UnlockPack, t.UnlockQuest)
}
}
return false
}
func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) {
codes := map[string]int{"/Attendance": 0, "/AttendanceInfo": 0, "/EventRewardHistory": 0, "/EventReward": 151, "/EventMissionInfo": 127, "/MissionClear": 120, "/MissionUpdate": 119, "/PassInfo": 124, "/PassReward": 126, "/PassBuy": 129, "/LoginEvent": 0}
code, found := codes[path]
if !found {
return 0, nil, false, nil
}
if path == "/MissionClear" && scalar(req, 3) != 2 {
return 0, nil, false, nil
}
if path == "/MissionUpdate" {
handled := false
_ = wire.Walk(req, func(f wire.Field) error {
if f.Number == 2 && f.Type == 2 && scalar(f.Value, 4) > 0 {
handled = true
}
return nil
})
if !handled {
return 0, nil, false, nil
}
}
s.mu.Lock()
defer s.mu.Unlock()
seq, present, err := wire.Varint(req, 1)
if err != nil || !present || seq == 0 {
return code, nil, true, errors.New("eventtasks: invalid sequence")
}
if err = wire.Walk(req, func(wire.Field) error { return nil }); err != nil {
return code, nil, true, err
}
digest := sha256.Sum256(append([]byte(path), req...))
ds := hex.EncodeToString(digest[:])
rk := s.session + ":" + path + ":" + key(seq)
if r, ok := s.state.Receipts[rk]; ok {
if r.Digest != ds {
return code, nil, true, errors.New("eventtasks: changed replay request")
}
return r.Code, append([]byte(nil), r.Reply...), true, nil
}
before, _ := json.Marshal(s.state)
out, err := s.handle(path, req, rk)
if err != nil {
_ = json.Unmarshal(before, &s.state)
return code, nil, true, err
}
s.state.Receipts[rk] = receipt{ds, code, out}
if err = s.save(); err != nil {
_ = json.Unmarshal(before, &s.state)
return code, nil, true, err
}
return code, out, true, nil
}
func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) {
switch path {
case "/Attendance", "/LoginEvent":
today := s.day()
if _, ok := s.state.LoginDays[today]; !ok {
s.state.LoginDays[today] = s.now().UnixMilli()
}
var out []byte
for _, v := range s.registry.List() {
if !s.active(v) {
continue
}
if v.Type > 1 {
continue
}
a := s.attendance(v)
if a.LastDay != today {
if v.Type == 0 {
complete, maxDay := true, uint64(0)
for _, r := range s.design.AttendanceRewards[a.Group] {
if r.Day > maxDay {
maxDay = r.Day
}
if !a.Obtained[key(a.Group, r.ID)] {
complete = false
}
}
if complete && maxDay > 0 && a.Count >= maxDay {
group := s.design.Attendance[v.ID].Group
if next := s.design.AttendanceGroups[[2]uint64{group, a.Group}].Next; next > 0 {
a.Group, a.Count = next, 0
for _, r := range s.design.AttendanceRewards[next] {
delete(a.Obtained, key(next, r.ID))
}
}
}
}
a.LastDay = today
a.Count++
if v.Type == 0 {
maxDay := uint64(0)
for _, r := range s.design.AttendanceRewards[a.Group] {
if r.Day > maxDay {
maxDay = r.Day
}
}
if maxDay > 0 && a.Count > maxDay {
a.Count = maxDay
}
}
}
if path == "/LoginEvent" {
x := wire.AppendVarint(nil, 1, v.UID)
x = wire.AppendVarint(x, 2, uint64(s.now().UTC().Truncate(24*time.Hour).Add(24*time.Hour).UnixMilli()))
out = wire.AppendBytes(out, 1, x)
continue
}
if v.Type == 0 {
x := wire.AppendVarint(nil, 1, v.UID)
x = wire.AppendVarint(x, 2, a.Group)
x = wire.AppendVarint(x, 3, a.Count)
out = wire.AppendBytes(out, 1, x)
} else {
x := wire.AppendVarint(nil, 1, v.UID)
for k, box := range s.design.LimitRewards {
if k[0] != v.ID {
continue
}
entry := wire.AppendVarint(nil, 1, k[1])
entry = wire.AppendVarint(entry, 2, box)
date := time.UnixMilli(v.Start).UTC().AddDate(0, 0, int(k[1])-1).Format("2006-01-02")
entry = wire.AppendBytes(entry, 3, []byte(date))
x = wire.AppendBytes(x, 2, entry)
}
out = wire.AppendBytes(out, 2, x)
}
for claim := range a.Obtained {
var group, id uint64
_, _ = fmt.Sscanf(claim, "%d/%d", &group, &id)
x := wire.AppendVarint(nil, 1, v.UID)
x = wire.AppendVarint(x, 2, group)
x = wire.AppendVarint(x, 3, id)
out = wire.AppendBytes(out, 5, x)
}
}
return out, nil
case "/AttendanceInfo":
var out []byte
start, end := scalar(b, 2), scalar(b, 3)
days := make([]string, 0, len(s.state.LoginDays))
for d := range s.state.LoginDays {
days = append(days, d)
}
sort.Strings(days)
for _, d := range days {
if len(d) != 10 {
continue
}
n, _ := strconv.ParseUint(d[0:4]+d[5:7]+d[8:10], 10, 64)
if (start == 0 || n >= start) && (end == 0 || n <= end) {
out = wire.AppendVarint(out, 1, uint64(s.state.LoginDays[d]))
}
}
return out, nil
case "/EventRewardHistory":
var out []byte
for uid, a := range s.state.Attendance {
groups := map[uint64][]uint64{}
for k := range a.History {
var g, id uint64
_, _ = fmt.Sscanf(k, "%d/%d", &g, &id)
groups[g] = append(groups[g], id)
}
var u uint64
_, _ = fmt.Sscanf(uid, "%d", &u)
for g, ids := range groups {
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
x := wire.AppendVarint(nil, 1, u)
x = wire.AppendVarint(x, 2, g)
for _, id := range ids {
x = wire.AppendVarint(x, 3, id)
}
out = wire.AppendBytes(out, 1, x)
}
}
return out, nil
case "/EventReward":
v, e := s.resolve(scalar(b, 2))
if e != nil {
return nil, e
}
if v.Type > 1 {
return nil, errors.New("eventtasks: reward is not attendance")
}
a := s.attendance(v)
g, id := scalar(b, 3), scalar(b, 4)
ck := key(g, id)
if a.Obtained[ck] {
return nil, errors.New("eventtasks: attendance already received")
}
var rewards []gamedata.Reward
if v.Type == 0 {
if g != a.Group {
return nil, errors.New("eventtasks: wrong attendance group")
}
for _, r := range s.design.AttendanceRewards[g] {
if r.ID == id && r.Day <= a.Count {
rewards = append(rewards, r.Basic)
if ticket := s.design.Attendance[v.ID].Ticket; ticket != 0 && s.attendancePremium != nil && s.attendancePremium(ticket) && r.Premium.Count > 0 {
rewards = append(rewards, r.Premium)
}
}
}
} else {
box, ok := s.design.LimitRewards[[2]uint64{g, id}]
date := time.UnixMilli(v.Start).UTC().AddDate(0, 0, int(id)-1).Format("2006-01-02")
_, logged := s.state.LoginDays[date]
if ok && g == v.ID && logged && date == s.day() {
rewards = []gamedata.Reward{{Type: 9, ID: box, Count: 1}}
}
}
if len(rewards) == 0 {
return nil, errors.New("eventtasks: attendance day unavailable")
}
bundle, e := s.economy.Apply(identity, nil, rewards)
if e != nil {
return nil, e
}
a.Obtained[ck] = true
a.History[ck] = true
// Frozen EventRewardResponse has no known fields. The owned-client plugin
// applies this actual grant once using the durable request receipt; Handle
// persists the complete response for replay before the account commits.
out := wire.AppendBytes(nil, 1001, bundle)
out = wire.AppendString(out, 1002, identity)
return out, nil
case "/EventMissionInfo":
var out []byte
for _, v := range s.taskSchedules() {
if v.Type != 4 || !s.active(v) {
continue
}
for _, t := range s.design.Missions {
if !s.availableTask(v, t) {
continue
}
m := s.mission(v, t.ID)
x := wire.AppendVarint(nil, 1, v.ID)
x = wire.AppendVarint(x, 2, t.Group)
x = wire.AppendVarint(x, 3, t.ID)
x = wire.AppendVarint(x, 4, m.Value)
if m.Claimed {
x = wire.AppendVarint(x, 5, 1)
}
out = wire.AppendBytes(out, 1, x)
}
}
return out, nil
case "/MissionUpdate":
err := wire.Walk(b, func(f wire.Field) error {
if f.Number != 2 || f.Type != 2 {
return nil
}
id, value, event := scalar(f.Value, 2), scalar(f.Value, 3), scalar(f.Value, 4)
v, e := s.find(4, event)
if e != nil {
return e
}
t, ok := s.design.Missions[id]
if !ok || t.Group != scalar(f.Value, 1) || !s.availableTask(v, t) {
return errors.New("eventtasks: invalid mission update")
}
if value > s.mission(v, id).Value {
return errors.New("eventtasks: client progress exceeds authoritative gameplay")
}
return nil
})
return nil, err
case "/MissionClear":
v, e := s.find(4, scalar(b, 6))
if e != nil {
return nil, e
}
all := scalar(b, 2) != 0
g, id := scalar(b, 4), scalar(b, 5)
var rewards []gamedata.Reward
var done []uint64
var exp uint64
for _, t := range s.design.Missions {
if t.Group != g || !s.availableTask(v, t) || !all && t.ID != id {
continue
}
m := s.mission(v, t.ID)
if !m.Claimed && m.Value >= t.Target {
rewards = append(rewards, t.Rewards...)
done = append(done, t.ID)
exp += t.PassExp
}
}
if len(done) == 0 {
return nil, errors.New("eventtasks: no completed unclaimed mission")
}
bundle, e := s.economy.Apply(identity, nil, rewards)
if e != nil {
return nil, e
}
for _, id := range done {
s.mission(v, id).Claimed = true
}
// Completion-of-mission conditions are derived from durable claims,
// rather than a client-supplied total.
for _, t := range s.design.Missions {
if !s.availableTask(v, t) || (t.Type != 1001 && t.Type != 1002) {
continue
}
var completed uint64
for _, other := range s.design.Missions {
if !s.availableTask(v, other) || !s.mission(v, other.ID).Claimed {
continue
}
match := other.Group == t.Group
if len(t.Params) > 0 {
match = false
for _, n := range t.Params {
if n == other.Group || n == other.ID {
match = true
}
}
}
if match {
completed++
}
}
m := s.mission(v, t.ID)
if completed > t.Target {
completed = t.Target
}
if completed > m.Value {
m.Value = completed
}
}
for _, pv := range s.registry.List() {
if pv.Type == 5 && s.active(pv) && s.design.Passes[pv.ID].MissionGroup == v.ID {
s.pass(pv).Exp += exp
}
}
return wire.AppendBytes(nil, 1, bundle), nil
case "/PassInfo":
var out []byte
for _, v := range s.registry.List() {
if v.Type != 5 || !s.active(v) {
continue
}
p := s.pass(v)
x := wire.AppendVarint(nil, 1, v.ID)
x = wire.AppendVarint(x, 2, p.Exp)
if p.Premium {
x = wire.AppendVarint(x, 3, 1)
}
out = wire.AppendBytes(out, 1, x)
d := s.design.Passes[v.ID]
for _, lv := range s.design.PassLevels[d.LevelGroup] {
basic, premium := p.Claimed[key(lv.ID, 0)], p.Claimed[key(lv.ID, 1)]
if !basic && !premium {
continue
}
x = wire.AppendVarint(nil, 1, v.ID)
x = wire.AppendVarint(x, 2, lv.ID)
if basic {
x = wire.AppendVarint(x, 3, 1)
}
if premium {
x = wire.AppendVarint(x, 4, 1)
}
out = wire.AppendBytes(out, 2, x)
}
}
return out, nil
case "/PassReward":
v, e := s.find(5, scalar(b, 3))
if e != nil {
return nil, e
}
d, ok := s.design.Passes[v.ID]
if !ok {
return nil, errors.New("eventtasks: unknown pass")
}
p := s.pass(v)
if d.NewbieStep > s.state.NewbieStep {
return nil, errors.New("eventtasks: guide pass step locked")
}
all, id, rt := scalar(b, 2) != 0, scalar(b, 4), scalar(b, 5)
if rt > 1 {
return nil, errors.New("eventtasks: invalid pass reward kind")
}
var rewards []gamedata.Reward
var claims []string
var threshold uint64
for _, lv := range s.design.PassLevels[d.LevelGroup] {
threshold += lv.NeedExp
if p.Exp < threshold || !all && lv.ID != id {
continue
}
for typ := uint64(0); typ <= 1; typ++ {
if !all && typ != rt || typ == 1 && !p.Premium {
continue
}
ck := key(lv.ID, typ)
if p.Claimed[ck] {
continue
}
r := lv.Basic
if typ == 1 {
r = lv.Premium
}
if r.Type > 0 && r.Count > 0 {
rewards = append(rewards, r)
}
claims = append(claims, ck)
}
}
if len(claims) == 0 {
return nil, errors.New("eventtasks: pass reward not eligible")
}
bundle, e := s.economy.Apply(identity, nil, rewards)
if e != nil {
return nil, e
}
for _, k := range claims {
p.Claimed[k] = true
}
out := wire.AppendBytes(nil, 1, bundle)
if d.NewbieStep > 0 {
complete := true
for _, lv := range s.design.PassLevels[d.LevelGroup] {
if !p.Claimed[key(lv.ID, 0)] {
complete = false
}
}
mv, err := s.find(4, d.MissionGroup)
if err != nil {
complete = false
} else {
for _, t := range s.design.Missions {
if s.availableTask(mv, t) && !s.mission(mv, t.ID).Claimed {
complete = false
}
}
}
if complete && s.state.NewbieStep == d.NewbieStep {
s.state.NewbieStep++
out = wire.AppendVarint(out, 2, s.state.NewbieStep)
}
}
return out, nil
case "/PassBuy":
v, e := s.find(5, scalar(b, 2))
if e != nil {
return nil, e
}
p := s.pass(v)
typ := scalar(b, 3)
var chosen *gamedata.EventPassBuy
for _, b := range s.design.PassBuys[v.ID] {
if b.Type == typ {
copy := b
chosen = &copy
break
}
}
if chosen == nil {
return nil, errors.New("eventtasks: unknown pass purchase")
}
var level, threshold uint64
for _, lv := range s.design.PassLevels[s.design.Passes[v.ID].LevelGroup] {
threshold += lv.NeedExp
if p.Exp >= threshold {
level = lv.ID
}
}
if level < chosen.UnlockLevel {
return nil, errors.New("eventtasks: pass purchase level locked")
}
// Private-server cash products follow the existing CashShopBuy policy:
// no external payment is charged. Premium activation is limited once per
// scheduled pass by durable Premium state. A supplied entitlement checker
// can restrict this policy for installations that integrate payments.
if chosen.CashID != 0 && s.authorizeCash != nil && !s.authorizeCash(v.ID, typ) {
return nil, errors.New("eventtasks: cash entitlement required")
}
if p.Premium && (typ == 1 || typ == 3) {
return nil, errors.New("eventtasks: premium already active")
}
var costs []gamedata.Reward
if chosen.Cost.Count > 0 {
costs = append(costs, chosen.Cost)
}
if _, e = s.economy.Apply(identity, costs, chosen.Rewards); e != nil {
return nil, e
}
if typ == 0 {
levels := s.design.PassLevels[s.design.Passes[v.ID].LevelGroup]
var threshold uint64
for _, lv := range levels {
threshold += lv.NeedExp
if threshold > p.Exp {
p.Exp = threshold
break
}
}
} else if typ == 1 || typ == 3 {
p.Premium = true
}
return wire.AppendVarint(nil, 1, p.Exp), nil
}
return nil, errors.New("eventtasks: unsupported operation")
}
// RecordEvent is called by authoritative gameplay. It returns no rewards and
// caps every matching task at its design threshold.
func (s *Service) RecordEvent(condition, sub, count uint64, unlocked func(uint64, uint64) bool) error {
s.mu.Lock()
defer s.mu.Unlock()
if count == 0 {
return nil
}
for _, v := range s.taskSchedules() {
if v.Type != 4 || !s.active(v) {
continue
}
for _, t := range s.design.Missions {
if t.Type != condition || t.SubType != 0 && t.SubType != sub || !s.availableTask(v, t) {
continue
}
if (t.UnlockPack > 0 || t.UnlockQuest > 0) && (unlocked == nil || !unlocked(t.UnlockPack, t.UnlockQuest)) {
continue
}
match := len(t.Params) == 0
for _, n := range t.Params {
if n == sub {
match = true
}
}
if !match {
continue
}
m := s.mission(v, t.ID)
if m.Claimed {
continue
}
if m.Value >= t.Target {
continue
}
if count >= t.Target-m.Value {
m.Value = t.Target
} else {
m.Value += count
}
}
}
return s.save()
}
func (s *Service) Notify() ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []byte
for _, v := range s.taskSchedules() {
if v.Type != 4 || !s.active(v) {
continue
}
groups := map[uint64][]byte{}
for _, t := range s.design.Missions {
if !s.availableTask(v, t) {
continue
}
m := s.mission(v, t.ID)
if m.Value == 0 {
continue
}
entry := wire.AppendVarint(nil, 1, t.ID)
entry = wire.AppendVarint(entry, 2, m.Value)
groups[t.Group] = wire.AppendBytes(groups[t.Group], 1, entry)
}
var groupMap []byte
for g, rows := range groups {
entry := wire.AppendVarint(nil, 1, g)
entry = wire.AppendBytes(entry, 2, rows)
groupMap = wire.AppendBytes(groupMap, 1, entry)
}
entry := wire.AppendVarint(nil, 1, v.ID)
entry = wire.AppendBytes(entry, 2, groupMap)
out = wire.AppendBytes(out, 4, entry)
}
return out, nil
}
// RecordLogin shares the same daily marker as Attendance, preventing repeated
// reconnects from inflating event login tasks. Main should invoke this once
// through the regular mission observer before serving attendance queries.
func (s *Service) RecordLogin(unlocked func(uint64, uint64) bool) error {
s.mu.Lock()
marker := "mission-login:" + s.day()
if _, ok := s.state.LoginDays[marker]; ok {
s.mu.Unlock()
return nil
}
s.state.LoginDays[marker] = s.now().UnixMilli()
s.mu.Unlock()
return s.RecordEvent(0, 0, 1, unlocked)
}
@@ -0,0 +1,181 @@
package eventtasks
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"testing"
"time"
)
type economyStub struct {
calls int
rewards []gamedata.Reward
}
func (e *economyStub) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) {
e.calls++
e.rewards = append(e.rewards, r...)
return []byte{10, 0}, nil
}
func setup(t *testing.T) (*Service, *economyStub, stateio.Store) {
t.Helper()
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
registry := events.NewRegistry()
if err := registry.Replace([]events.Schedule{{UID: 1, Type: 0, ID: 1, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(24 * time.Hour).UnixMilli()}, {UID: 2, Type: 4, ID: 7, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(24 * time.Hour).UnixMilli()}, {UID: 3, Type: 5, ID: 8, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(24 * time.Hour).UnixMilli()}}); err != nil {
t.Fatal(err)
}
d := &gamedata.EventTasksDesign{Attendance: map[uint64]gamedata.EventAttendance{1: {ID: 1, Group: 1}}, AttendanceRewards: map[uint64][]gamedata.EventAttendanceReward{1: {{ID: 1, Group: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 100}}}}, MissionGroups: map[uint64]gamedata.EventMissionGroup{7: {ID: 7, Groups: []uint64{9}}}, Missions: map[uint64]gamedata.EventTask{10: {ID: 10, Group: 9, Type: 2, Target: 2, PassExp: 20, Rewards: []gamedata.Reward{{Type: 4, Count: 5}}}}, Passes: map[uint64]gamedata.EventPass{8: {ID: 8, MissionGroup: 7, LevelGroup: 8}}, PassLevels: map[uint64][]gamedata.EventPassLevel{8: {{ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 8, ID: 1000, Count: 1}}}}}
e := &economyStub{}
store := stateio.NewMemory()
s, err := Open(store, d, registry, e)
if err != nil {
t.Fatal(err)
}
s.now = func() time.Time { return now }
s.SetSession("test")
return s, e, store
}
func req(seq uint64) []byte { return wire.AppendVarint(nil, 1, seq) }
func TestMissionRejectsFabricatedProgressAndReplaySurvivesRestart(t *testing.T) {
s, e, store := setup(t)
update := req(1)
x := wire.AppendVarint(nil, 1, 9)
x = wire.AppendVarint(x, 2, 10)
x = wire.AppendVarint(x, 3, 2)
x = wire.AppendVarint(x, 4, 7)
update = wire.AppendBytes(update, 2, x)
if _, _, _, err := s.Handle("/MissionUpdate", update); err == nil {
t.Fatal("client manufactured completion")
}
if err := s.RecordEvent(2, 0, 2, nil); err != nil {
t.Fatal(err)
}
clear := req(2)
clear = wire.AppendVarint(clear, 2, 1)
clear = wire.AppendVarint(clear, 3, 2)
clear = wire.AppendVarint(clear, 4, 9)
clear = wire.AppendVarint(clear, 6, 7)
_, reply, _, err := s.Handle("/MissionClear", clear)
if err != nil {
t.Fatal(err)
}
if e.calls != 1 || s.pass(s.registry.List()[2]).Exp != 20 {
t.Fatal("settlement or pass exp missing")
}
next, err := Open(store, s.design, s.registry, e)
if err != nil {
t.Fatal(err)
}
next.now = s.now
next.SetSession("test")
_, replay, _, err := next.Handle("/MissionClear", clear)
if err != nil || !bytes.Equal(reply, replay) || e.calls != 1 {
t.Fatal("restart replay duplicated reward")
}
mutated := wire.AppendVarint(clear, 5, 10)
if _, _, _, err = next.Handle("/MissionClear", mutated); err == nil {
t.Fatal("changed replay accepted")
}
passReq := wire.AppendVarint(req(3), 2, 1)
passReq = wire.AppendVarint(passReq, 3, 8)
if _, _, _, err = next.Handle("/PassReward", passReq); err != nil {
t.Fatal(err)
}
if e.calls != 2 {
t.Fatal("pass reward not granted")
}
}
func TestAttendanceDailyCounterAndClaimEligibility(t *testing.T) {
s, e, _ := setup(t)
if _, _, _, err := s.Handle("/Attendance", req(1)); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/Attendance", req(2)); err != nil {
t.Fatal(err)
}
if s.state.Attendance["1"].Count != 1 {
t.Fatal("reconnect counted attendance twice")
}
claim := req(3)
claim = wire.AppendVarint(claim, 2, 1)
claim = wire.AppendVarint(claim, 3, 1)
claim = wire.AppendVarint(claim, 4, 1)
if _, _, _, err := s.Handle("/EventReward", claim); err != nil {
t.Fatal(err)
}
if e.calls != 1 {
t.Fatal("attendance reward missing")
}
again := wire.AppendVarint(nil, 1, 4)
again = append(again, claim[2:]...)
if _, _, _, err := s.Handle("/EventReward", again); err == nil {
t.Fatal("claimed attendance awarded again")
}
}
func TestAttendanceNextGroupAndRepeatedChainReward(t *testing.T) {
s, e, _ := setup(t)
s.design.Attendance[1] = gamedata.EventAttendance{ID: 1, Group: 20}
s.design.AttendanceGroups = map[[2]uint64]gamedata.EventAttendanceGroup{{20, 5}: {Group: 20, ID: 5, Next: 6}, {20, 6}: {Group: 20, ID: 6, Next: 5}}
s.design.AttendanceRewards = map[uint64][]gamedata.EventAttendanceReward{5: {{Group: 5, ID: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 5}}}, 6: {{Group: 6, ID: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 6}}}}
today := s.now()
s.now = func() time.Time { return today }
registry := events.NewRegistry()
if err := registry.Replace([]events.Schedule{{UID: 1, Type: 0, ID: 1, Start: today.Add(-24 * time.Hour).UnixMilli(), End: today.Add(7 * 24 * time.Hour).UnixMilli()}}); err != nil {
t.Fatal(err)
}
s.registry = registry
seq := uint64(1)
for _, group := range []uint64{5, 6, 5} {
if _, _, _, err := s.Handle("/Attendance", req(seq)); err != nil {
t.Fatal(err)
}
seq++
if s.state.Attendance["1"].Group != group || s.state.Attendance["1"].Count != 1 {
t.Fatalf("wrong chain state %+v", s.state.Attendance["1"])
}
claim := wire.AppendVarint(req(seq), 2, 1)
claim = wire.AppendVarint(claim, 3, group)
claim = wire.AppendVarint(claim, 4, 1)
if _, _, _, err := s.Handle("/EventReward", claim); err != nil {
t.Fatal(err)
}
seq++
today = today.Add(24 * time.Hour)
}
if e.calls != 3 || len(s.state.Attendance["1"].History) != 2 {
t.Fatal("repeated cycle reward or retained history missing")
}
}
func TestPrivateServerPremiumCashPassOnceAndRestartReplay(t *testing.T) {
s, e, store := setup(t)
s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {{ID: 1, Type: 1, CashID: 42, Rewards: []gamedata.Reward{{Type: 4, Count: 10}}}}}
request := wire.AppendVarint(req(1), 2, 8)
request = wire.AppendVarint(request, 3, 1)
_, reply, _, err := s.Handle("/PassBuy", request)
if err != nil {
t.Fatal(err)
}
if e.calls != 1 || !s.state.Passes["3"].Premium {
t.Fatal("premium cash pass not activated")
}
reopened, err := Open(store, s.design, s.registry, e)
if err != nil {
t.Fatal(err)
}
reopened.now = s.now
reopened.SetSession("test")
_, again, _, err := reopened.Handle("/PassBuy", request)
if err != nil || !bytes.Equal(reply, again) || e.calls != 1 {
t.Fatal("cash pass replay duplicated reward")
}
duplicate := wire.AppendVarint(req(2), 2, 8)
duplicate = wire.AppendVarint(duplicate, 3, 1)
if _, _, _, err = reopened.Handle("/PassBuy", duplicate); err == nil || e.calls != 1 {
t.Fatal("premium cash purchase granted twice")
}
}
-4
View File
@@ -32,19 +32,15 @@ var emptyResponses = map[string]int{
"/EvilCastleDailyRewardState": 441,
"/EvilCastleTowerInfo": 200,
"/FieldTrapInfo": 171,
"/FireWorksInfo": 554,
"/FishingCollectionInfo": 465,
"/FishingTrapInfo": 457,
"/FriendInfoList": 204,
"/FriendshipSpecialEpisodeInfo": 623,
"/GuildInitInfo": 332,
"/IdCardPresetInfo": 450,
"/LifeUserInfo": 594,
"/MiniEventHubInfo": 534,
"/MyLikeInfo": 218,
"/MyRoomItemInfo": 232,
"/PersonalInfo": 199,
"/PrestigeSkinInfo": 425,
"/QuestMaxClearInfo": 137,
"/SpineInteractionAchievementInfo": 532,
"/TotalWarRewardState": 252,
+5 -4
View File
@@ -8,9 +8,6 @@ import (
)
func TestHandleAuditedEmptyResponses(t *testing.T) {
if got := len(EmptyPacketCodes()); got != 29 {
t.Fatalf("audited empty-response registry has %d paths, want 29", got)
}
for path, wantCode := range EmptyPacketCodes() {
t.Run(path, func(t *testing.T) {
gotCode, gotProto, ok, err := Handle(path, wire.AppendVarint(nil, 1, 42))
@@ -22,6 +19,11 @@ func TestHandleAuditedEmptyResponses(t *testing.T) {
}
func TestHandleRejectsUnknownAndInvalidRequests(t *testing.T) {
for _, path := range []string{"/EventScheduleInfo", "/LoginEvent", "/ChargeCostInfo", "/EventMissionInfo", "/EventRewardHistory", "/PackEventStoryInfo", "/PackEventBattleInfo", "/Attendance", "/AttendanceInfo", "/FireWorksInfo", "/FriendshipSpecialEpisodeInfo", "/MiniEventHubInfo", "/PrestigeSkinInfo"} {
if _, _, ok, err := Handle(path, wire.AppendVarint(nil, 1, 1)); ok || err != nil {
t.Fatalf("stateful event route %s has stateless fallback", path)
}
}
for _, path := range []string{"/HuntDispatchInfo", "/HuntingGroundInfo", "/HuntingGroundInfoList", "/HuntingGroundEnter", "/UserLevelReward", "/FieldObjectInfo", "/WaypointInfo", "/WaypointSave", "/WaypointUse", "/AchievementInfo", "/AchievementUpdate", "/RecipeInfo", "/PackInfo", "/DeckInfo", "/RootSortIdInfo"} {
if _, _, handled, err := Handle(path, wire.AppendVarint(nil, 1, 1)); handled || err != nil {
t.Fatalf("stateful route %s still has a stateless success fallback", path)
@@ -63,7 +65,6 @@ func TestEmptyPacketCodesReturnsCopy(t *testing.T) {
func TestStandaloneNativeDefaults(t *testing.T) {
for path, want := range map[string]int{
"/EventScheduleInfo": 163,
"/EquipInfo": 34,
"/TodayQuestInfo": 64,
"/UpdateAgeGate": 0,
-9
View File
@@ -4,19 +4,10 @@ package feature
// features. An empty protobuf means the local account currently has no rows or
// no active server schedule; it is not copied from a captured response.
var standaloneDefaults = map[string]int{
"/EventScheduleInfo": 163,
"/LoginEvent": 0,
"/BalanceVersionCheck": 186,
"/ChargeCostInfo": 123,
"/EquipInfo": 34,
"/EventMissionInfo": 127,
"/MissionInfo": 118,
"/EventRewardHistory": 0,
"/PackEventStoryInfo": 220,
"/PackEventBattleInfo": 214,
"/FishingItemInfo": 459,
"/Attendance": 0,
"/AttendanceInfo": 0,
// No PvP decks, battle history or one-time reward claims have been saved.
// Their repeated-only protobufs encode these account states as empty, and
// the client's callbacks continue the arena-lobby entry sequence.
+15 -53
View File
@@ -1,12 +1,8 @@
package gacha
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"math"
"bd2server/internal/server/gamedata"
)
@@ -65,64 +61,30 @@ type ScheduleWindow struct {
CashCountBonus bool `json:"cash_count_bonus,omitempty"`
}
func LoadScheduleSeed(path, clientVersion string) (*ScheduleSeed, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("gacha: read schedule seed: %w", err)
}
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
var seed ScheduleSeed
if err := decoder.Decode(&seed); err != nil {
return nil, fmt.Errorf("gacha: decode schedule seed: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err == nil {
return nil, errors.New("gacha: schedule seed has trailing JSON")
} else if !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("gacha: decode schedule seed trailing data: %w", err)
}
if err := seed.Validate(clientVersion); err != nil {
return nil, err
}
return &seed, nil
}
func (s *ScheduleSeed) Validate(clientVersion string) error {
if s == nil || clientVersion == "" || s.ClientVersion != clientVersion {
return fmt.Errorf("gacha: schedule seed client version %q does not match %q", seedVersion(s), clientVersion)
}
if len(s.Schedules) == 0 || len(s.StepUps) == 0 {
return errors.New("gacha: schedule seed requires regular and step-up windows")
}
seen := make(map[uint64]bool, len(s.Schedules))
for _, window := range s.Schedules {
if err := validateWindow(window); err != nil {
return err
for n, rows := range [][]ScheduleWindow{s.Schedules, s.StepUps} {
for i, window := range rows {
if err := validateWindow(window); err != nil {
return err
}
if n == 1 && (window.FreeCountBonus || window.CashCountBonus) {
return fmt.Errorf("gacha: step-up group %d has unsupported bonus flags", window.GroupID)
}
for _, previous := range rows[:i] {
if previous.GroupID == window.GroupID && previous.StartTime < window.EndTime && window.StartTime < previous.EndTime {
return fmt.Errorf("gacha: overlapping schedule group %d", window.GroupID)
}
}
}
if seen[window.GroupID] {
return fmt.Errorf("gacha: duplicate schedule group %d", window.GroupID)
}
seen[window.GroupID] = true
}
seen = make(map[uint64]bool, len(s.StepUps))
for _, window := range s.StepUps {
if window.FreeCountBonus || window.CashCountBonus {
return fmt.Errorf("gacha: step-up group %d has unsupported bonus flags", window.GroupID)
}
if err := validateWindow(window); err != nil {
return err
}
if seen[window.GroupID] {
return fmt.Errorf("gacha: duplicate step-up group %d", window.GroupID)
}
seen[window.GroupID] = true
}
return nil
}
func validateWindow(window ScheduleWindow) error {
if window.GroupID == 0 || window.StartTime == 0 || window.EndTime <= window.StartTime {
if window.GroupID == 0 || window.GroupID > math.MaxInt32 || window.StartTime == 0 || window.EndTime > math.MaxInt64 || window.EndTime <= window.StartTime {
return fmt.Errorf("gacha: invalid schedule window for group %d", window.GroupID)
}
return nil
+3 -43
View File
@@ -2,8 +2,6 @@ package gacha
import (
"encoding/base64"
"os"
"path/filepath"
"testing"
"bd2server/internal/server/gamedata"
@@ -12,50 +10,12 @@ import (
"bd2server/internal/server/wire"
)
func TestVersionedScheduleMatchesOfficial23510GachaInfo(t *testing.T) {
seed, err := LoadScheduleSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "gacha_schedule.json"), "2.35.10")
if err != nil {
t.Fatal(err)
}
// Official 2.35.10 GachaInfo, captured 2026-09-23. It is evidence used by
// this test only; production loads the semantic JSON seed above.
func fixtureSchedule(t *testing.T) (*ScheduleSeed, error) {
golden, err := base64.StdEncoding.DecodeString("ChEIpgEQgMi/3Iw0GJiIvcaRNAoSCLrqARCA8PTEiDQYmIi9xpE0ChIIu+oBEIDw9MSINBiYiL3GkTQKEQjOARCA8PTEiDQYmIi9xpE0ChEIzQEQgPD0xIg0GJiIvcaRNAoRCNABEIDIv9yMNBiYiL3GkTQKEQiZARCAyL/cjDQYmIi9xpE0ChAISBCAyL/cjDQYmIi9xpE0ChAIRxCAyL/cjDQYmIi9xpE0ChEIzwEQgMi/3Iw0GJiIvcaRNAoRCPEHEIDo2beLNBiA+L34jzQSBAgCGAo6EAgdEIDw9MSINBiYiL3GkTQ6EAgeEIDIv9yMNBiYiL3GkTRKUAiTCUJLGgMQvR4aBRCFBzABGgUQ0ggwAhoGEOn7AzADGgYQzeMDMAQaBhDF7gMwBRoGEJnsAzAGGgYQvfkDMAcaBhDu6QMwCBoGEKnvAzAJ")
if err != nil {
t.Fatal(err)
}
regular := collectScheduleWindows(t, golden, 1)
steps := collectScheduleWindows(t, golden, 7)
if len(regular) != len(seed.Schedules) || len(steps) != len(seed.StepUps) {
t.Fatalf("golden schedules=%d/%d seed=%d/%d", len(regular), len(steps), len(seed.Schedules), len(seed.StepUps))
}
for i := range seed.Schedules {
if regular[i] != seed.Schedules[i] {
t.Fatalf("schedule %d golden=%+v seed=%+v", i, regular[i], seed.Schedules[i])
}
}
for i := range seed.StepUps {
if steps[i] != seed.StepUps[i] {
t.Fatalf("step-up %d golden=%+v seed=%+v", i, steps[i], seed.StepUps[i])
}
}
if countFields(golden, 9) != 1 {
t.Fatal("official golden must retain the player preview used to verify field 9 separately")
}
}
func TestScheduleSeedStrictValidation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "gacha.json")
bad := `{"client_version":"2.35.10","schedules":[{"group_id":1,"start_time":1,"end_time":2}],"step_ups":[{"group_id":2,"start_time":1,"end_time":2}],"unknown":true}`
if err := os.WriteFile(path, []byte(bad), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadScheduleSeed(path, "2.35.10"); err == nil {
t.Fatal("unknown schedule field was accepted")
}
if _, err := LoadScheduleSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "gacha_schedule.json"), "0.0.0"); err == nil {
t.Fatal("wrong client version was accepted")
}
return &ScheduleSeed{ClientVersion: "2.35.10", Schedules: collectScheduleWindows(t, golden, 1), StepUps: collectScheduleWindows(t, golden, 7)}, nil
}
func TestActivePickupCostumesUsesHalfOpenScheduleWindows(t *testing.T) {
@@ -88,7 +48,7 @@ func TestActivePickupCostumesUsesHalfOpenScheduleWindows(t *testing.T) {
}
func TestGachaInfoUsesInjectedScheduleAndEmptyAccountHasNoPreview(t *testing.T) {
seed, err := LoadScheduleSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "gacha_schedule.json"), "2.35.10")
seed, err := fixtureSchedule(t)
if err != nil {
t.Fatal(err)
}
+28 -4
View File
@@ -14,9 +14,22 @@ import (
const FileName = "game.json"
type Config struct {
SchemaVersion int `json:"schema_version"`
Gacha GachaConfig `json:"gacha"`
Story StoryConfig `json:"story"`
SchemaVersion int `json:"schema_version"`
Gacha GachaConfig `json:"gacha"`
Story StoryConfig `json:"story"`
Purchases PurchasesConfig `json:"purchases"`
}
type PurchasesConfig struct {
DiamondRecharge DiamondRechargeConfig `json:"diamond_recharge"`
}
// Only paid-diamond recharge has a configurable price. All other cash goods
// use their GameData-derived paid-diamond price.
type DiamondRechargeConfig struct {
Currency string `json:"currency"`
GoldPerPaidDiamond int64 `json:"gold_per_paid_diamond"`
DiamondsPerPaidDiamond int64 `json:"diamonds_per_paid_diamond"`
}
// StartPackID is fixed for the server by its first successful initialization.
@@ -31,7 +44,9 @@ type GachaConfig struct {
IncludeCollaborationURWeapons bool `json:"include_collaboration_ur_weapons"`
}
func Default() Config { return Config{SchemaVersion: 1, Story: StoryConfig{StartPackID: 21}} }
func Default() Config {
return Config{SchemaVersion: 1, Story: StoryConfig{StartPackID: 21}, Purchases: PurchasesConfig{DiamondRecharge: DiamondRechargeConfig{Currency: "free", GoldPerPaidDiamond: 1000, DiamondsPerPaidDiamond: 1}}}
}
// Load reads one explicit path. Older installations without the file retain
// the default rules; an existing invalid file must never silently fall back.
@@ -97,6 +112,15 @@ func (c Config) Validate() error {
if c.Story.StartPackID != 1 && c.Story.StartPackID != 21 {
return fmt.Errorf("story.start_pack_id must be 1 or 21, got %d", c.Story.StartPackID)
}
r := c.Purchases.DiamondRecharge
switch r.Currency {
case "free", "gold", "diamonds", "ban", "":
default:
return fmt.Errorf("purchases.diamond_recharge.currency must be free, gold, diamonds, ban, or an empty string")
}
if r.GoldPerPaidDiamond < 1 || r.GoldPerPaidDiamond > 1_000_000_000 || r.DiamondsPerPaidDiamond < 1 || r.DiamondsPerPaidDiamond > 1_000_000_000 {
return fmt.Errorf("purchases.diamond_recharge conversion rates must be integers between 1 and 1000000000")
}
return nil
}
@@ -0,0 +1,39 @@
package gameconfig
import (
"os"
"path/filepath"
"testing"
)
func TestDiamondRechargeConfiguration(t *testing.T) {
for _, tc := range []struct {
text, currency string
invalid bool
}{
{`{"schema_version":1}`, "free", false},
{`{"schema_version":1,"purchases":{}}`, "free", false},
{`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":""}}}`, "", false},
{`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":"ban"}}}`, "ban", false},
{`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":"gold","gold_per_paid_diamond":10}}}`, "gold", false},
{`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":"diamonds","diamonds_per_paid_diamond":2}}}`, "diamonds", false},
{`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":"paid_diamonds"}}}`, "", true},
{`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":null}}}`, "", true},
{`{"schema_version":1,"purchases":{"diamond_recharge":{"gold_per_paid_diamond":0}}}`, "", true},
{`{"schema_version":1,"purchases":{"diamond_recharge":{"gold_per_paid_diamond":1000000001}}}`, "", true},
{`{"schema_version":1,"purchases":{"diamond_recharge":{"diamonds_per_paid_diamond":1.1}}}`, "", true},
{`{"schema_version":1,"purchases":{"diamond_recharge":{"typo":1}}}`, "", true},
} {
path := filepath.Join(t.TempDir(), FileName)
if err := os.WriteFile(path, []byte(tc.text), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if (err != nil) != tc.invalid {
t.Fatalf("%s: error=%v", tc.text, err)
}
if err == nil && cfg.Purchases.DiamondRecharge.Currency != tc.currency {
t.Fatalf("%s: %+v", tc.text, cfg.Purchases)
}
}
}
+2 -18
View File
@@ -1,11 +1,8 @@
package gamedata
import (
"database/sql"
"encoding/binary"
"fmt"
"os"
"path/filepath"
"bd2server/internal/server/wire"
_ "modernc.org/sqlite"
@@ -38,24 +35,11 @@ func BattleRewards(root, version string, packID, monsterID int) ([]BattleReward,
}
func readBattleRewards(root, version string, packID, deckID, monsterID int) ([]BattleReward, error) {
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID))
db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID))
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-battle-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pack.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
if monsterID != 0 {
var monster []byte
if err := db.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", monsterID).Scan(&monster); err != nil {
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
)
// BattleDeckForDifficulty mirrors QuicklyQuestHelper.GetBattleDeckIdByDifficulty
@@ -23,25 +21,7 @@ func openPackDatabase(root, version string, packID int) (*sql.DB, func(), error)
if packID <= 0 {
return nil, nil, fmt.Errorf("gamedata: invalid battle pack %d", packID)
}
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID))
if err != nil {
return nil, nil, err
}
dir, err := os.MkdirTemp("", "bd2-difficulty-")
if err != nil {
return nil, nil, err
}
path := filepath.Join(dir, "pack.db")
if err := os.WriteFile(path, plain, 0600); err != nil {
os.RemoveAll(dir)
return nil, nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
os.RemoveAll(dir)
return nil, nil, err
}
return db, func() { db.Close(); os.RemoveAll(dir) }, nil
return OpenDatabase(root, version, fmt.Sprintf("pack%d", packID))
}
func battleDeckForDifficultyFromDB(db *sql.DB, deckID, difficulty uint64) (uint64, error) {
+2 -17
View File
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"bd2server/internal/server/wire"
)
@@ -19,24 +17,11 @@ func BattleDeckPhases(root, version string, packID int, monsterID, deckID uint64
if packID <= 0 || monsterID == 0 || deckID == 0 || monsterID > 2147483647 || deckID > 2147483647 {
return nil, fmt.Errorf("gamedata: invalid phase battle pack/monster/deck %d/%d/%d", packID, monsterID, deckID)
}
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID))
db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID))
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-phase-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pack.db")
if err := os.WriteFile(path, plain, 0600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
return battleDeckPhasesFromDB(db, monsterID, deckID)
}
@@ -0,0 +1,39 @@
package gamedata
import "fmt"
// LoadMonsterHuntIDs reads only supported hunt identities. Historical calendar
// entries remain display metadata even after their gameplay design is removed.
func LoadMonsterHuntIDs(root, version string) (map[uint64]bool, error) {
return loadCalendarIDs(root, version, "MonsterHuntTable")
}
// LoadCalendarPackIDs includes story and event packs, rather than only the
// arena subset returned by LoadFieldPacks.
func LoadCalendarPackIDs(root, version string) (map[uint64]bool, error) {
return loadCalendarIDs(root, version, "PackTable")
}
func loadCalendarIDs(root, version, table string) (map[uint64]bool, error) {
db, done, e := openStatDatabase(root, version)
if e != nil {
return nil, e
}
defer done()
rows, e := db.Query("SELECT id FROM " + table)
if e != nil {
return nil, e
}
defer rows.Close()
ids := map[uint64]bool{}
for rows.Next() {
var id uint64
if e = rows.Scan(&id); e != nil {
return nil, e
}
if id == 0 {
return nil, fmt.Errorf("gamedata: invalid monster hunt identity")
}
ids[id] = true
}
return ids, rows.Err()
}
@@ -0,0 +1,91 @@
package gamedata
import (
"database/sql"
"fmt"
"sort"
)
type CashAttendanceReward struct {
ID uint64
Reward BattleReward
}
type CashEntitlementDesign struct {
AvatarSets map[uint64]bool
TicketTypes map[uint64]uint64
Attendance map[uint64][]CashAttendanceReward
AttendanceTypes map[uint64]uint64
}
func LoadCashEntitlementDesign(root, version string) (*CashEntitlementDesign, error) {
db, done, e := openStatDatabase(root, version)
if e != nil {
return nil, e
}
defer done()
return loadCashEntitlementDesign(db)
}
func loadCashEntitlementDesign(db *sql.DB) (*CashEntitlementDesign, error) {
d := &CashEntitlementDesign{AvatarSets: map[uint64]bool{}, TicketTypes: map[uint64]uint64{}, Attendance: map[uint64][]CashAttendanceReward{}, AttendanceTypes: map[uint64]uint64{}}
if e := readCashMetadata(db, "AvatarSetTable", func(raw []byte) error {
v, e := cashScalars(raw, 6)
if e != nil {
return e
}
if v[0] == 0 {
return fmt.Errorf("gamedata: invalid avatar set")
}
d.AvatarSets[v[0]] = true
return nil
}); e != nil {
return nil, e
}
if e := readCashMetadata(db, "ContentTicketTable", func(raw []byte) error {
v, e := cashScalars(raw, 4, 8)
if e != nil {
return e
}
d.TicketTypes[v[0]] = v[1]
return nil
}); e != nil {
return nil, e
}
if e := readCashMetadata(db, "ContentOpenTable", func(raw []byte) error {
v, e := cashScalars(raw, 6, 2)
if e != nil {
return e
}
if d.TicketTypes[v[0]] == 2 {
d.AttendanceTypes[v[0]] = v[1]
}
return nil
}); e != nil {
return nil, e
}
if e := readCashMetadata(db, "AttendanceRewardTable", func(raw []byte) error {
v, e := cashScalars(raw, 1, 2, 5, 4, 3)
if e != nil {
return e
}
if v[0] == 0 || v[1] == 0 || v[2] == 0 || v[4] == 0 {
return fmt.Errorf("gamedata: invalid cash attendance reward")
}
d.Attendance[v[0]] = append(d.Attendance[v[0]], CashAttendanceReward{v[1], BattleReward{Type: v[2], ID: v[3], Count: v[4]}})
return nil
}); e != nil {
return nil, e
}
for ticket, rows := range d.Attendance {
sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
for i, r := range rows {
if r.ID != uint64(i+1) {
return nil, fmt.Errorf("gamedata: non-contiguous cash attendance %d", ticket)
}
}
if d.TicketTypes[ticket] != 2 && d.TicketTypes[ticket] != 3 {
return nil, fmt.Errorf("gamedata: missing cash attendance ticket %d", ticket)
}
d.Attendance[ticket] = rows
}
return d, nil
}

Some files were not shown because too many files have changed in this diff Show More