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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,3 +5,5 @@
|
||||
!authentication.json
|
||||
!resources.json
|
||||
!game.json
|
||||
!schedules/
|
||||
!schedules/**
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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。
|
||||
@@ -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 校验状态格式。公共日历保留未来、当前和归档记录,玩法入口及进行中标记根据时间和玩家状态限制。
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = ©
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user