diff --git a/.gitignore b/.gitignore
index 31497dd..f5b4499 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/Dockerfile.server b/Dockerfile.server
index 50acca8..c6896c2 100644
--- a/Dockerfile.server
+++ b/Dockerfile.server
@@ -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
diff --git a/Dockerfile.server.dockerignore b/Dockerfile.server.dockerignore
index 564c5d7..be567cb 100644
--- a/Dockerfile.server.dockerignore
+++ b/Dockerfile.server.dockerignore
@@ -5,3 +5,5 @@
!authentication.json
!resources.json
!game.json
+!schedules/
+!schedules/**
diff --git a/GAME_CONFIGURATION.md b/GAME_CONFIGURATION.md
index 9486540..928c234 100644
--- a/GAME_CONFIGURATION.md
+++ b/GAME_CONFIGURATION.md
@@ -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`。
diff --git a/README.md b/README.md
index 62b20f0..c3b68ff 100644
--- a/README.md
+++ b/README.md
@@ -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)。
diff --git a/RELEASE.md b/RELEASE.md
index 8bb6cde..6b9b330 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -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。
diff --git a/build-release.ps1 b/build-release.ps1
index 22931ec..42a3911 100644
--- a/build-release.ps1
+++ b/build-release.ps1
@@ -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
diff --git a/docs/CLIENT.md b/docs/CLIENT.md
new file mode 100644
index 0000000..56add50
--- /dev/null
+++ b/docs/CLIENT.md
@@ -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:。
+
+当前本机发布脚本只生成 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: /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。
diff --git a/docs/SCHEDULES.md b/docs/SCHEDULES.md
new file mode 100644
index 0000000..3479063
--- /dev/null
+++ b/docs/SCHEDULES.md
@@ -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 校验状态格式。公共日历保留未来、当前和归档记录,玩法入口及进行中标记根据时间和玩家状态限制。
diff --git a/game.json b/game.json
index 4a69b02..ebee8b9 100644
--- a/game.json
+++ b/game.json
@@ -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
+ }
}
}
diff --git a/go/cmd/bd2client/dev_command.go b/go/cmd/bd2client/dev_command.go
index 32f275d..40f91e3 100644
--- a/go/cmd/bd2client/dev_command.go
+++ b/go/cmd/bd2client/dev_command.go
@@ -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))
diff --git a/go/cmd/bd2client/dev_command_test.go b/go/cmd/bd2client/dev_command_test.go
index 1f3fdce..c12dfb5 100644
--- a/go/cmd/bd2client/dev_command_test.go
+++ b/go/cmd/bd2client/dev_command_test.go
@@ -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)
diff --git a/go/cmd/bd2client/dev_sdk_integration_test.go b/go/cmd/bd2client/dev_sdk_integration_test.go
index f98f9a3..916d0e2 100644
--- a/go/cmd/bd2client/dev_sdk_integration_test.go
+++ b/go/cmd/bd2client/dev_sdk_integration_test.go
@@ -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)
}
diff --git a/go/cmd/bd2client/main.go b/go/cmd/bd2client/main.go
index 478107c..b9fdecb 100644
--- a/go/cmd/bd2client/main.go
+++ b/go/cmd/bd2client/main.go
@@ -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
diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go
index 7292171..31cf0e9 100644
--- a/go/cmd/bd2server/main.go
+++ b/go/cmd/bd2server/main.go
@@ -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,
diff --git a/go/cmd/bd2server/preflight.go b/go/cmd/bd2server/preflight.go
index e2ff0ab..e52fa59 100644
--- a/go/cmd/bd2server/preflight.go
+++ b/go/cmd/bd2server/preflight.go
@@ -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)
diff --git a/go/internal/client/app/app.go b/go/internal/client/app/app.go
index 1cfbbd1..4d08a75 100644
--- a/go/internal/client/app/app.go
+++ b/go/internal/client/app/app.go
@@ -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)
diff --git a/go/internal/client/config/release.go b/go/internal/client/config/release.go
index 2d54a47..ad92720 100644
--- a/go/internal/client/config/release.go
+++ b/go/internal/client/config/release.go
@@ -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"`
}
diff --git a/go/internal/client/plugin/install.go b/go/internal/client/plugin/install.go
index 6644205..71923f9 100644
--- a/go/internal/client/plugin/install.go
+++ b/go/internal/client/plugin/install.go
@@ -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 {
diff --git a/go/internal/client/plugin/install_test.go b/go/internal/client/plugin/install_test.go
index 4c4e695..d942363 100644
--- a/go/internal/client/plugin/install_test.go
+++ b/go/internal/client/plugin/install_test.go
@@ -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() {
diff --git a/go/internal/client/setup/setup.go b/go/internal/client/setup/setup.go
index eee3174..09f6b0a 100644
--- a/go/internal/client/setup/setup.go
+++ b/go/internal/client/setup/setup.go
@@ -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) {
diff --git a/go/internal/server/account/login.go b/go/internal/server/account/login.go
index d15d004..bd65f4a 100644
--- a/go/internal/server/account/login.go
+++ b/go/internal/server/account/login.go
@@ -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 {
diff --git a/go/internal/server/battle/battle.go b/go/internal/server/battle/battle.go
index 95a55f4..b7a8831 100644
--- a/go/internal/server/battle/battle.go
+++ b/go/internal/server/battle/battle.go
@@ -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)
diff --git a/go/internal/server/battle/event_test.go b/go/internal/server/battle/event_test.go
new file mode 100644
index 0000000..c3f4c63
--- /dev/null
+++ b/go/internal/server/battle/event_test.go
@@ -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")
+ }
+}
diff --git a/go/internal/server/battle/hunting_test.go b/go/internal/server/battle/hunting_test.go
index fdaf817..453c2ef 100644
--- a/go/internal/server/battle/hunting_test.go
+++ b/go/internal/server/battle/hunting_test.go
@@ -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")
}
}
diff --git a/go/internal/server/calendar/binary.go b/go/internal/server/calendar/binary.go
new file mode 100644
index 0000000..dd0e2a5
--- /dev/null
+++ b/go/internal/server/calendar/binary.go
@@ -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
+}
diff --git a/go/internal/server/calendar/binary_test.go b/go/internal/server/calendar/binary_test.go
new file mode 100644
index 0000000..e2dd61b
--- /dev/null
+++ b/go/internal/server/calendar/binary_test.go
@@ -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")
+ }
+}
diff --git a/go/internal/server/calendar/design.go b/go/internal/server/calendar/design.go
new file mode 100644
index 0000000..f549593
--- /dev/null
+++ b/go/internal/server/calendar/design.go
@@ -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
+}
diff --git a/go/internal/server/calendar/load.go b/go/internal/server/calendar/load.go
new file mode 100644
index 0000000..310537f
--- /dev/null
+++ b/go/internal/server/calendar/load.go
@@ -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
+}
diff --git a/go/internal/server/calendar/load_test.go b/go/internal/server/calendar/load_test.go
new file mode 100644
index 0000000..fd53b55
--- /dev/null
+++ b/go/internal/server/calendar/load_test.go
@@ -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)
+ }
+}
diff --git a/go/internal/server/calendar/readonly.go b/go/internal/server/calendar/readonly.go
new file mode 100644
index 0000000..bd664c6
--- /dev/null
+++ b/go/internal/server/calendar/readonly.go
@@ -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
+}
diff --git a/go/internal/server/calendar/records.go b/go/internal/server/calendar/records.go
new file mode 100644
index 0000000..02f1c03
--- /dev/null
+++ b/go/internal/server/calendar/records.go
@@ -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
+}
diff --git a/go/internal/server/calendar/schema.go b/go/internal/server/calendar/schema.go
new file mode 100644
index 0000000..491c275
--- /dev/null
+++ b/go/internal/server/calendar/schema.go
@@ -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
+}
diff --git a/go/internal/server/commerce/attendance.go b/go/internal/server/commerce/attendance.go
new file mode 100644
index 0000000..c6da918
--- /dev/null
+++ b/go/internal/server/commerce/attendance.go
@@ -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
+}
diff --git a/go/internal/server/commerce/attendance_test.go b/go/internal/server/commerce/attendance_test.go
new file mode 100644
index 0000000..ce6fc43
--- /dev/null
+++ b/go/internal/server/commerce/attendance_test.go
@@ -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")
+ }
+}
diff --git a/go/internal/server/commerce/catalog.go b/go/internal/server/commerce/catalog.go
new file mode 100644
index 0000000..6835c73
--- /dev/null
+++ b/go/internal/server/commerce/catalog.go
@@ -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
+}
diff --git a/go/internal/server/commerce/catalog_test.go b/go/internal/server/commerce/catalog_test.go
new file mode 100644
index 0000000..e47d5e8
--- /dev/null
+++ b/go/internal/server/commerce/catalog_test.go
@@ -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")
+ }
+}
diff --git a/go/internal/server/commerce/clear_packages.go b/go/internal/server/commerce/clear_packages.go
new file mode 100644
index 0000000..cba6cbd
--- /dev/null
+++ b/go/internal/server/commerce/clear_packages.go
@@ -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
+}
diff --git a/go/internal/server/commerce/clear_packages_test.go b/go/internal/server/commerce/clear_packages_test.go
new file mode 100644
index 0000000..e26d61c
--- /dev/null
+++ b/go/internal/server/commerce/clear_packages_test.go
@@ -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")
+ }
+}
diff --git a/go/internal/server/commerce/entitlements.go b/go/internal/server/commerce/entitlements.go
new file mode 100644
index 0000000..42a2183
--- /dev/null
+++ b/go/internal/server/commerce/entitlements.go
@@ -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
+}
diff --git a/go/internal/server/commerce/entitlements_test.go b/go/internal/server/commerce/entitlements_test.go
new file mode 100644
index 0000000..d7deb77
--- /dev/null
+++ b/go/internal/server/commerce/entitlements_test.go
@@ -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")
+ }
+}
diff --git a/go/internal/server/commerce/installed_test.go b/go/internal/server/commerce/installed_test.go
new file mode 100644
index 0000000..de2a6ac
--- /dev/null
+++ b/go/internal/server/commerce/installed_test.go
@@ -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))
+}
diff --git a/go/internal/server/commerce/login_pass.go b/go/internal/server/commerce/login_pass.go
new file mode 100644
index 0000000..1eda7cb
--- /dev/null
+++ b/go/internal/server/commerce/login_pass.go
@@ -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
+}
diff --git a/go/internal/server/commerce/login_pass_test.go b/go/internal/server/commerce/login_pass_test.go
new file mode 100644
index 0000000..1785c5f
--- /dev/null
+++ b/go/internal/server/commerce/login_pass_test.go
@@ -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)
+ }
+}
diff --git a/go/internal/server/commerce/pack_info.go b/go/internal/server/commerce/pack_info.go
new file mode 100644
index 0000000..c4130ba
--- /dev/null
+++ b/go/internal/server/commerce/pack_info.go
@@ -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
+}
diff --git a/go/internal/server/commerce/service.go b/go/internal/server/commerce/service.go
new file mode 100644
index 0000000..cfb5a27
--- /dev/null
+++ b/go/internal/server/commerce/service.go
@@ -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
+}
diff --git a/go/internal/server/commerce/service_test.go b/go/internal/server/commerce/service_test.go
new file mode 100644
index 0000000..c04f2c0
--- /dev/null
+++ b/go/internal/server/commerce/service_test.go
@@ -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")
+ }
+}
diff --git a/go/internal/server/eventactions/battle.go b/go/internal/server/eventactions/battle.go
new file mode 100644
index 0000000..10589b0
--- /dev/null
+++ b/go/internal/server/eventactions/battle.go
@@ -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[:]) }
diff --git a/go/internal/server/eventactions/cafeteria.go b/go/internal/server/eventactions/cafeteria.go
new file mode 100644
index 0000000..c4351f8
--- /dev/null
+++ b/go/internal/server/eventactions/cafeteria.go
@@ -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
+}
diff --git a/go/internal/server/eventactions/cafeteria_test.go b/go/internal/server/eventactions/cafeteria_test.go
new file mode 100644
index 0000000..cdb7973
--- /dev/null
+++ b/go/internal/server/eventactions/cafeteria_test.go
@@ -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")
+ }
+}
diff --git a/go/internal/server/eventactions/service.go b/go/internal/server/eventactions/service.go
new file mode 100644
index 0000000..19a9c11
--- /dev/null
+++ b/go/internal/server/eventactions/service.go
@@ -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()
+}
diff --git a/go/internal/server/eventactions/service_test.go b/go/internal/server/eventactions/service_test.go
new file mode 100644
index 0000000..da30651
--- /dev/null
+++ b/go/internal/server/eventactions/service_test.go
@@ -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")
+ }
+}
diff --git a/go/internal/server/eventactions/voting.go b/go/internal/server/eventactions/voting.go
new file mode 100644
index 0000000..05e4fa7
--- /dev/null
+++ b/go/internal/server/eventactions/voting.go
@@ -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
+}
diff --git a/go/internal/server/eventexchange/service.go b/go/internal/server/eventexchange/service.go
new file mode 100644
index 0000000..b15db6f
--- /dev/null
+++ b/go/internal/server/eventexchange/service.go
@@ -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
+}
diff --git a/go/internal/server/eventexchange/service_test.go b/go/internal/server/eventexchange/service_test.go
new file mode 100644
index 0000000..71dfdd8
--- /dev/null
+++ b/go/internal/server/eventexchange/service_test.go
@@ -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")
+ }
+ }
+}
diff --git a/go/internal/server/eventgames/SPEC.md b/go/internal/server/eventgames/SPEC.md
new file mode 100644
index 0000000..47cdf8d
--- /dev/null
+++ b/go/internal/server/eventgames/SPEC.md
@@ -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.
diff --git a/go/internal/server/eventgames/games.go b/go/internal/server/eventgames/games.go
new file mode 100644
index 0000000..7e0f3b9
--- /dev/null
+++ b/go/internal/server/eventgames/games.go
@@ -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
+}
diff --git a/go/internal/server/eventgames/games_test.go b/go/internal/server/eventgames/games_test.go
new file mode 100644
index 0000000..2a9b4ad
--- /dev/null
+++ b/go/internal/server/eventgames/games_test.go
@@ -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)
+ }
+}
diff --git a/go/internal/server/eventgames/play.go b/go/internal/server/eventgames/play.go
new file mode 100644
index 0000000..98fec81
--- /dev/null
+++ b/go/internal/server/eventgames/play.go
@@ -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
+}
diff --git a/go/internal/server/eventgames/validation.go b/go/internal/server/eventgames/validation.go
new file mode 100644
index 0000000..fa94b84
--- /dev/null
+++ b/go/internal/server/eventgames/validation.go
@@ -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
+}
diff --git a/go/internal/server/eventplay/SPEC.md b/go/internal/server/eventplay/SPEC.md
new file mode 100644
index 0000000..6cd30fd
--- /dev/null
+++ b/go/internal/server/eventplay/SPEC.md
@@ -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.
diff --git a/go/internal/server/eventplay/battle.go b/go/internal/server/eventplay/battle.go
new file mode 100644
index 0000000..454b627
--- /dev/null
+++ b/go/internal/server/eventplay/battle.go
@@ -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
+}
diff --git a/go/internal/server/eventplay/hub_calendar_test.go b/go/internal/server/eventplay/hub_calendar_test.go
new file mode 100644
index 0000000..a616d82
--- /dev/null
+++ b/go/internal/server/eventplay/hub_calendar_test.go
@@ -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")
+ }
+}
diff --git a/go/internal/server/eventplay/play.go b/go/internal/server/eventplay/play.go
new file mode 100644
index 0000000..7042d65
--- /dev/null
+++ b/go/internal/server/eventplay/play.go
@@ -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)
+}
diff --git a/go/internal/server/eventplay/play_test.go b/go/internal/server/eventplay/play_test.go
new file mode 100644
index 0000000..499e9e6
--- /dev/null
+++ b/go/internal/server/eventplay/play_test.go
@@ -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 }
diff --git a/go/internal/server/eventplay/progress.go b/go/internal/server/eventplay/progress.go
new file mode 100644
index 0000000..f1b42a2
--- /dev/null
+++ b/go/internal/server/eventplay/progress.go
@@ -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
+}
diff --git a/go/internal/server/eventplay/projection.go b/go/internal/server/eventplay/projection.go
new file mode 100644
index 0000000..c34b430
--- /dev/null
+++ b/go/internal/server/eventplay/projection.go
@@ -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")
+}
diff --git a/go/internal/server/eventplay/room.go b/go/internal/server/eventplay/room.go
new file mode 100644
index 0000000..b15e8fc
--- /dev/null
+++ b/go/internal/server/eventplay/room.go
@@ -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
+}
diff --git a/go/internal/server/eventplay/routes.go b/go/internal/server/eventplay/routes.go
new file mode 100644
index 0000000..14f912c
--- /dev/null
+++ b/go/internal/server/eventplay/routes.go
@@ -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,
+}
diff --git a/go/internal/server/eventplay/runtime.go b/go/internal/server/eventplay/runtime.go
new file mode 100644
index 0000000..2ed3f11
--- /dev/null
+++ b/go/internal/server/eventplay/runtime.go
@@ -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 }
diff --git a/go/internal/server/eventplay/summary.go b/go/internal/server/eventplay/summary.go
new file mode 100644
index 0000000..c24ace9
--- /dev/null
+++ b/go/internal/server/eventplay/summary.go
@@ -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
+}
diff --git a/go/internal/server/eventplay/survival.go b/go/internal/server/eventplay/survival.go
new file mode 100644
index 0000000..8702775
--- /dev/null
+++ b/go/internal/server/eventplay/survival.go
@@ -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
+}
diff --git a/go/internal/server/eventplay/survival_test.go b/go/internal/server/eventplay/survival_test.go
new file mode 100644
index 0000000..275aaf4
--- /dev/null
+++ b/go/internal/server/eventplay/survival_test.go
@@ -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")
+ }
+}
diff --git a/go/internal/server/eventplay/upgrade.go b/go/internal/server/eventplay/upgrade.go
new file mode 100644
index 0000000..c2bc17b
--- /dev/null
+++ b/go/internal/server/eventplay/upgrade.go
@@ -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
+}
diff --git a/go/internal/server/events/ap_refresh.go b/go/internal/server/events/ap_refresh.go
new file mode 100644
index 0000000..074fc32
--- /dev/null
+++ b/go/internal/server/events/ap_refresh.go
@@ -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
+}
diff --git a/go/internal/server/events/boxes.go b/go/internal/server/events/boxes.go
new file mode 100644
index 0000000..d522c4d
--- /dev/null
+++ b/go/internal/server/events/boxes.go
@@ -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
+}
diff --git a/go/internal/server/events/economy.go b/go/internal/server/events/economy.go
new file mode 100644
index 0000000..bd21f30
--- /dev/null
+++ b/go/internal/server/events/economy.go
@@ -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)
+}
diff --git a/go/internal/server/events/economy_test.go b/go/internal/server/events/economy_test.go
new file mode 100644
index 0000000..2695c3f
--- /dev/null
+++ b/go/internal/server/events/economy_test.go
@@ -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")
+ }
+}
diff --git a/go/internal/server/events/owned_items.go b/go/internal/server/events/owned_items.go
new file mode 100644
index 0000000..9498164
--- /dev/null
+++ b/go/internal/server/events/owned_items.go
@@ -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
+}
diff --git a/go/internal/server/events/registry.go b/go/internal/server/events/registry.go
new file mode 100644
index 0000000..bfc2aef
--- /dev/null
+++ b/go/internal/server/events/registry.go
@@ -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
+}
diff --git a/go/internal/server/events/registry_test.go b/go/internal/server/events/registry_test.go
new file mode 100644
index 0000000..046f702
--- /dev/null
+++ b/go/internal/server/events/registry_test.go
@@ -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")
+ }
+}
diff --git a/go/internal/server/events/skin_handler.go b/go/internal/server/events/skin_handler.go
new file mode 100644
index 0000000..71d3043
--- /dev/null
+++ b/go/internal/server/events/skin_handler.go
@@ -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)
+}
diff --git a/go/internal/server/events/skins.go b/go/internal/server/events/skins.go
new file mode 100644
index 0000000..d622827
--- /dev/null
+++ b/go/internal/server/events/skins.go
@@ -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
+}
diff --git a/go/internal/server/eventtasks/calendar_identity_test.go b/go/internal/server/eventtasks/calendar_identity_test.go
new file mode 100644
index 0000000..b0162a9
--- /dev/null
+++ b/go/internal/server/eventtasks/calendar_identity_test.go
@@ -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)
+ }
+}
diff --git a/go/internal/server/eventtasks/observer.go b/go/internal/server/eventtasks/observer.go
new file mode 100644
index 0000000..7b47ba0
--- /dev/null
+++ b/go/internal/server/eventtasks/observer.go
@@ -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
+}
diff --git a/go/internal/server/eventtasks/paid_attendance_test.go b/go/internal/server/eventtasks/paid_attendance_test.go
new file mode 100644
index 0000000..9903528
--- /dev/null
+++ b/go/internal/server/eventtasks/paid_attendance_test.go
@@ -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)
+ }
+ }
+}
diff --git a/go/internal/server/eventtasks/service.go b/go/internal/server/eventtasks/service.go
new file mode 100644
index 0000000..7cecdc4
--- /dev/null
+++ b/go/internal/server/eventtasks/service.go
@@ -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)
+}
diff --git a/go/internal/server/eventtasks/service_test.go b/go/internal/server/eventtasks/service_test.go
new file mode 100644
index 0000000..f5222fb
--- /dev/null
+++ b/go/internal/server/eventtasks/service_test.go
@@ -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")
+ }
+}
diff --git a/go/internal/server/feature/feature.go b/go/internal/server/feature/feature.go
index 8de889c..85a485f 100644
--- a/go/internal/server/feature/feature.go
+++ b/go/internal/server/feature/feature.go
@@ -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,
diff --git a/go/internal/server/feature/feature_test.go b/go/internal/server/feature/feature_test.go
index 964f610..c2d011a 100644
--- a/go/internal/server/feature/feature_test.go
+++ b/go/internal/server/feature/feature_test.go
@@ -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,
diff --git a/go/internal/server/feature/standalone.go b/go/internal/server/feature/standalone.go
index 3f3324f..beb0df8 100644
--- a/go/internal/server/feature/standalone.go
+++ b/go/internal/server/feature/standalone.go
@@ -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.
diff --git a/go/internal/server/gacha/schedule.go b/go/internal/server/gacha/schedule.go
index 6744d1e..d01c33f 100644
--- a/go/internal/server/gacha/schedule.go
+++ b/go/internal/server/gacha/schedule.go
@@ -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
diff --git a/go/internal/server/gacha/schedule_test.go b/go/internal/server/gacha/schedule_test.go
index b67be34..e306ab2 100644
--- a/go/internal/server/gacha/schedule_test.go
+++ b/go/internal/server/gacha/schedule_test.go
@@ -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)
}
diff --git a/go/internal/server/gameconfig/config.go b/go/internal/server/gameconfig/config.go
index a7d9e0f..777f938 100644
--- a/go/internal/server/gameconfig/config.go
+++ b/go/internal/server/gameconfig/config.go
@@ -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
}
diff --git a/go/internal/server/gameconfig/purchases_test.go b/go/internal/server/gameconfig/purchases_test.go
new file mode 100644
index 0000000..ed1d4fe
--- /dev/null
+++ b/go/internal/server/gameconfig/purchases_test.go
@@ -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)
+ }
+ }
+}
diff --git a/go/internal/server/gamedata/battle.go b/go/internal/server/gamedata/battle.go
index c29db66..b519764 100644
--- a/go/internal/server/gamedata/battle.go
+++ b/go/internal/server/gamedata/battle.go
@@ -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 {
diff --git a/go/internal/server/gamedata/battle_difficulty.go b/go/internal/server/gamedata/battle_difficulty.go
index 1e3c027..a28ab1d 100644
--- a/go/internal/server/gamedata/battle_difficulty.go
+++ b/go/internal/server/gamedata/battle_difficulty.go
@@ -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) {
diff --git a/go/internal/server/gamedata/battle_phase.go b/go/internal/server/gamedata/battle_phase.go
index 1e6441c..ac4ec2c 100644
--- a/go/internal/server/gamedata/battle_phase.go
+++ b/go/internal/server/gamedata/battle_phase.go
@@ -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)
}
diff --git a/go/internal/server/gamedata/calendar_ids.go b/go/internal/server/gamedata/calendar_ids.go
new file mode 100644
index 0000000..9a6972d
--- /dev/null
+++ b/go/internal/server/gamedata/calendar_ids.go
@@ -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()
+}
diff --git a/go/internal/server/gamedata/cash_entitlements.go b/go/internal/server/gamedata/cash_entitlements.go
new file mode 100644
index 0000000..e254920
--- /dev/null
+++ b/go/internal/server/gamedata/cash_entitlements.go
@@ -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
+}
diff --git a/go/internal/server/gamedata/cash_entitlements_test.go b/go/internal/server/gamedata/cash_entitlements_test.go
new file mode 100644
index 0000000..069e773
--- /dev/null
+++ b/go/internal/server/gamedata/cash_entitlements_test.go
@@ -0,0 +1,38 @@
+package gamedata
+
+import (
+ "bd2server/internal/server/wire"
+ "database/sql"
+ "testing"
+)
+
+func TestCashEntitlementDesignUsesTicketTypesAndAttendanceMapping(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ for _, table := range []string{"AvatarSetTable", "ContentTicketTable", "ContentOpenTable", "AttendanceRewardTable"} {
+ if _, err = db.Exec("CREATE TABLE " + table + "(ProtoBuf BLOB)"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ db.Exec("INSERT INTO AvatarSetTable VALUES(?)", wire.AppendVarint(nil, 6, 10))
+ raw := wire.AppendVarint(nil, 4, 38)
+ raw = wire.AppendVarint(raw, 8, 2)
+ db.Exec("INSERT INTO ContentTicketTable VALUES(?)", raw)
+ raw = wire.AppendVarint(nil, 6, 38)
+ raw = wire.AppendVarint(raw, 2, 1)
+ db.Exec("INSERT INTO ContentOpenTable VALUES(?)", raw)
+ for _, id := range []uint64{2, 1} {
+ raw = wire.AppendVarint(nil, 1, 38)
+ raw = wire.AppendVarint(raw, 2, id)
+ raw = wire.AppendVarint(raw, 3, 60)
+ raw = wire.AppendVarint(raw, 5, 3)
+ db.Exec("INSERT INTO AttendanceRewardTable VALUES(?)", raw)
+ }
+ d, err := loadCashEntitlementDesign(db)
+ if err != nil || !d.AvatarSets[10] || d.TicketTypes[38] != 2 || d.AttendanceTypes[38] != 1 || len(d.Attendance[38]) != 2 || d.Attendance[38][0].ID != 1 {
+ t.Fatalf("%+v %v", d, err)
+ }
+}
diff --git a/go/internal/server/gamedata/cash_products.go b/go/internal/server/gamedata/cash_products.go
new file mode 100644
index 0000000..66a767c
--- /dev/null
+++ b/go/internal/server/gamedata/cash_products.go
@@ -0,0 +1,217 @@
+package gamedata
+
+import (
+ "database/sql"
+ "fmt"
+ "math"
+
+ "bd2server/internal/server/wire"
+)
+
+// CashProductKey is the official three-part identity; an SDK SKU can be shared
+// by several product variants and must not replace this key.
+type CashProductKey struct{ GroupID, ProductID, SaleGroup uint64 }
+type CashProductDesign struct {
+ Key CashProductKey
+ GoogleSKU, AppleSKU string
+ PriceType, PriceID, PriceCount uint64
+ RandomBoxID, BonusRandomBoxID uint64
+ PurchaseLimitType, PurchaseLimitCount, TimeLimitType, BulkOrderAvailability uint64
+ LocalTextID uint64
+ Recharge bool
+ NominalPaidDiamonds uint64
+}
+type CashShopDesign struct{ GroupID, ID, ProductGroupID, BulkOrderShow uint64 }
+type CashPackageDesign struct{ GroupID, ID, SaleGroup, PackageType, PaidShopGroupID, PaidShopID, ContentsGroupID, ContentsSortID uint64 }
+type CashCatalog struct {
+ Products []CashProductDesign
+ Shops []CashShopDesign
+ Packages []CashPackageDesign
+}
+
+func LoadCashCatalog(root, version string) (*CashCatalog, error) {
+ db, done, err := openStatDatabase(root, version)
+ if err != nil {
+ return nil, err
+ }
+ defer done()
+ return loadCashCatalog(db)
+}
+func loadCashCatalog(db *sql.DB) (*CashCatalog, error) {
+ c := &CashCatalog{}
+ rows, err := db.Query("SELECT ProtoBuf FROM CashProductTable ORDER BY groupId,id,saleGroup")
+ if err != nil {
+ return nil, err
+ }
+ for rows.Next() {
+ var raw []byte
+ if err = rows.Scan(&raw); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ p, e := decodeCashProduct(raw)
+ if e != nil {
+ rows.Close()
+ return nil, e
+ }
+ c.Products = append(c.Products, p)
+ }
+ err = rows.Err()
+ rows.Close()
+ if err != nil {
+ return nil, err
+ }
+ seen := map[CashProductKey]bool{}
+ // Direct single paid-diamond rewards identify recharge offers. The least
+ // grant among variants of the same group/sale tier is the regular amount,
+ // so first-purchase doubling never doubles the configurable price.
+ nominal := map[[2]uint64]uint64{}
+ for i := range c.Products {
+ p := &c.Products[i]
+ if seen[p.Key] {
+ return nil, fmt.Errorf("gamedata: duplicate cash product %+v", p.Key)
+ }
+ seen[p.Key] = true
+ if p.PriceType != 1 || p.RandomBoxID == 0 {
+ continue
+ }
+ n, e := cashPaidDiamondReward(db, p.RandomBoxID)
+ if e != nil {
+ return nil, fmt.Errorf("gamedata: cash product %+v: %w", p.Key, e)
+ }
+ if n == 0 {
+ continue
+ }
+ p.Recharge = true
+ k := [2]uint64{p.Key.GroupID, p.Key.SaleGroup}
+ if nominal[k] == 0 || n < nominal[k] {
+ nominal[k] = n
+ }
+ }
+ for i := range c.Products {
+ p := &c.Products[i]
+ if p.Recharge {
+ p.NominalPaidDiamonds = nominal[[2]uint64{p.Key.GroupID, p.Key.SaleGroup}]
+ }
+ }
+ if err = readCashMetadata(db, "CashShopTable", func(raw []byte) error {
+ v, e := cashScalars(raw, 4, 5, 10, 2)
+ if e != nil {
+ return e
+ }
+ c.Shops = append(c.Shops, CashShopDesign{v[0], v[1], v[2], v[3]})
+ return nil
+ }); err != nil {
+ return nil, err
+ }
+ if err = readCashMetadata(db, "CashPackageTable", func(raw []byte) error {
+ v, e := cashScalars(raw, 8, 9, 17, 11, 12, 13, 4, 7)
+ if e != nil {
+ return e
+ }
+ c.Packages = append(c.Packages, CashPackageDesign{v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7]})
+ return nil
+ }); err != nil {
+ return nil, err
+ }
+ return c, nil
+}
+func readCashMetadata(db *sql.DB, table string, visit func([]byte) error) error {
+ rows, err := db.Query("SELECT ProtoBuf FROM " + table)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var raw []byte
+ if err = rows.Scan(&raw); err != nil {
+ return err
+ }
+ if err = visit(raw); err != nil {
+ return err
+ }
+ }
+ return rows.Err()
+}
+func cashScalars(raw []byte, fields ...int) ([]uint64, error) {
+ out := make([]uint64, len(fields))
+ for i, f := range fields {
+ v, e := optionalScalar(raw, f)
+ if e != nil {
+ return nil, e
+ }
+ if v > math.MaxInt32 {
+ return nil, fmt.Errorf("gamedata: cash field %d outside int32", f)
+ }
+ out[i] = v
+ }
+ return out, nil
+}
+func decodeCashProduct(raw []byte) (CashProductDesign, error) {
+ v, e := cashScalars(raw, 5, 6, 15, 9, 8, 7, 14, 2, 13, 12, 16, 3, 11)
+ if e != nil {
+ return CashProductDesign{}, e
+ }
+ google, _, e := wire.Bytes(raw, 4)
+ if e != nil {
+ return CashProductDesign{}, e
+ }
+ apple, _, e := wire.Bytes(raw, 1)
+ if e != nil {
+ return CashProductDesign{}, e
+ }
+ p := CashProductDesign{Key: CashProductKey{v[0], v[1], v[2]}, GoogleSKU: string(google), AppleSKU: string(apple), PriceType: v[3], PriceID: v[4], PriceCount: v[5], RandomBoxID: v[6], BonusRandomBoxID: v[7], PurchaseLimitType: v[8], PurchaseLimitCount: v[9], TimeLimitType: v[10], BulkOrderAvailability: v[11], LocalTextID: v[12]}
+ if p.Key.GroupID == 0 || p.Key.ProductID == 0 {
+ return p, fmt.Errorf("gamedata: invalid cash product key %+v", p.Key)
+ }
+ if p.PriceType == 1 && (p.GoogleSKU == "" && p.AppleSKU == "" || p.PriceCount == 0) {
+ return p, fmt.Errorf("gamedata: cash product lacks SKU/price %+v", p.Key)
+ }
+ return p, nil
+}
+
+// Mixed packages, random selection, nested boxes and non-diamond leaves are
+// ordinary goods. Only the audited pure direct paid-diamond shape qualifies.
+func cashPaidDiamondReward(db *sql.DB, box uint64) (uint64, error) {
+ var raw []byte
+ if err := db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", box).Scan(&raw); err != nil {
+ return 0, err
+ }
+ gid, e := optionalScalar(raw, 9)
+ if e != nil {
+ return 0, e
+ }
+ if gid == 0 {
+ return 0, nil
+ }
+ if e = db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", gid).Scan(&raw); e != nil {
+ return 0, e
+ }
+ drop, e := optionalScalar(raw, 2)
+ if e != nil {
+ return 0, e
+ }
+ types, e := packedInts(raw, 6)
+ if e != nil {
+ return 0, e
+ }
+ ids, e := packedInts(raw, 5)
+ if e != nil {
+ return 0, e
+ }
+ counts, e := packedInts(raw, 4)
+ if e != nil {
+ return 0, e
+ }
+ ratios, e := packedInts(raw, 8)
+ if e != nil {
+ return 0, e
+ }
+ if drop != 1 || len(types) != 1 || len(ids) != 1 || len(counts) != 1 || len(ratios) != 1 || types[0] != 2 || ids[0] != 0 || ratios[0] != 100 {
+ return 0, nil
+ }
+ if counts[0] == 0 || counts[0] > math.MaxInt32 {
+ return 0, fmt.Errorf("gamedata: invalid paid-diamond recharge amount")
+ }
+ return counts[0], nil
+}
diff --git a/go/internal/server/gamedata/cash_products_test.go b/go/internal/server/gamedata/cash_products_test.go
new file mode 100644
index 0000000..e9af6ff
--- /dev/null
+++ b/go/internal/server/gamedata/cash_products_test.go
@@ -0,0 +1,77 @@
+package gamedata
+
+import (
+ "bd2server/internal/server/wire"
+ "database/sql"
+ "testing"
+)
+
+func TestCashCatalogPreservesKeysAliasesAndLimits(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ db.SetMaxOpenConns(1)
+ for _, q := range []string{"CREATE TABLE CashProductTable(groupId INTEGER,id INTEGER,saleGroup INTEGER,ProtoBuf BLOB)", "CREATE TABLE RandomBoxTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE RewardGroupTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE CashShopTable(ProtoBuf BLOB)", "CREATE TABLE CashPackageTable(ProtoBuf BLOB)"} {
+ if _, err = db.Exec(q); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for i, count := range []uint64{200, 100} {
+ raw := wire.AppendVarint(nil, 2, 1)
+ raw = wire.AppendVarint(raw, 6, 2)
+ raw = wire.AppendVarint(raw, 5, 0)
+ raw = wire.AppendVarint(raw, 4, count)
+ raw = wire.AppendVarint(raw, 8, 100)
+ if _, err = db.Exec("INSERT INTO RewardGroupTable VALUES(?,?)", i+1, raw); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = db.Exec("INSERT INTO RandomBoxTable VALUES(?,?)", i+1, wire.AppendVarint(nil, 9, uint64(i+1))); err != nil {
+ t.Fatal(err)
+ }
+ raw = wire.AppendString(nil, 4, "shared-sku")
+ raw = wire.AppendString(raw, 1, "shared-ios")
+ for _, f := range []struct {
+ n int
+ v uint64
+ }{{5, 99}, {6, uint64(i + 1)}, {15, 7}, {9, 1}, {7, 2200}, {14, uint64(i + 1)}, {2, 333}, {12, 1}, {13, 3}, {16, 2}, {3, 1}} {
+ raw = wire.AppendVarint(raw, f.n, f.v)
+ }
+ if _, err = db.Exec("INSERT INTO CashProductTable VALUES(99,?,7,?)", i+1, raw); err != nil {
+ t.Fatal(err)
+ }
+ }
+ shop := wire.AppendVarint(nil, 4, 8)
+ shop = wire.AppendVarint(shop, 5, 1)
+ shop = wire.AppendVarint(shop, 10, 99)
+ if _, err = db.Exec("INSERT INTO CashShopTable VALUES(?)", shop); err != nil {
+ t.Fatal(err)
+ }
+ packageRaw := wire.AppendVarint(nil, 8, 99)
+ packageRaw = wire.AppendVarint(packageRaw, 9, 1)
+ packageRaw = wire.AppendVarint(packageRaw, 17, 7)
+ packageRaw = wire.AppendVarint(packageRaw, 11, 12)
+ if _, err = db.Exec("INSERT INTO CashPackageTable VALUES(?)", packageRaw); err != nil {
+ t.Fatal(err)
+ }
+ c, err := loadCashCatalog(db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Products) != 2 || len(c.Shops) != 1 || len(c.Packages) != 1 {
+ t.Fatalf("incomplete %+v", c)
+ }
+ for _, p := range c.Products {
+ if !p.Recharge || p.NominalPaidDiamonds != 100 || p.GoogleSKU != "shared-sku" || p.AppleSKU != "shared-ios" || p.BonusRandomBoxID != 333 || p.PurchaseLimitType != 3 || p.TimeLimitType != 2 {
+ t.Fatalf("lost metadata %+v", p)
+ }
+ }
+}
+func TestDecodeCashProductRejectsMalformedMonetaryFields(t *testing.T) {
+ for _, raw := range [][]byte{{255}, wire.AppendVarint(nil, 5, 1), wire.AppendVarint(wire.AppendVarint(nil, 5, 1), 6, 1<<40)} {
+ if _, err := decodeCashProduct(raw); err == nil {
+ t.Fatalf("accepted %x", raw)
+ }
+ }
+}
diff --git a/go/internal/server/gamedata/cash_rewards.go b/go/internal/server/gamedata/cash_rewards.go
new file mode 100644
index 0000000..73ff968
--- /dev/null
+++ b/go/internal/server/gamedata/cash_rewards.go
@@ -0,0 +1,103 @@
+package gamedata
+
+import (
+ "fmt"
+ "math"
+)
+
+// CashRewardResolver gives cash combination groups their storefront meaning:
+// ratio 1 and ratio 100 both describe guaranteed components. Weighted groups
+// keep the shared reward graph sampler, and manually opened boxes stay items.
+type CashRewardResolver struct {
+ graph *RewardGraph
+ boxes map[uint64]uint64
+ direct map[uint64]bool
+ groups map[uint64][]byte
+}
+
+func LoadCashRewardResolver(root, version string, shared ...*RewardGraph) (*CashRewardResolver, error) {
+ var graph *RewardGraph
+ if len(shared) > 0 {
+ graph = shared[0]
+ }
+ if graph == nil {
+ var err error
+ graph, err = LoadRewardGraph(root, version)
+ if err != nil {
+ return nil, err
+ }
+ }
+ // The shared immutable design maps are initialized completely at startup.
+ return &CashRewardResolver{graph: graph, boxes: graph.boxes, direct: graph.direct, groups: graph.groups}, nil
+}
+func (c *CashRewardResolver) ResolveGranted(rewards []BattleReward) ([]BattleReward, error) {
+ budget := uint64(100000)
+ visiting := map[uint64]bool{}
+ var out []BattleReward
+ var emit func(BattleReward, bool) error
+ emit = func(r BattleReward, force bool) error {
+ if budget == 0 || r.Count == 0 || r.Count > math.MaxInt32 || r.Type == 0 {
+ return fmt.Errorf("gamedata: invalid cash reward/budget")
+ }
+ budget--
+ if r.Type != 9 || !force && !c.direct[r.ID] {
+ out = append(out, r)
+ return nil
+ }
+ if visiting[r.ID] || len(visiting) >= 32 {
+ return fmt.Errorf("gamedata: cash reward cycle")
+ }
+ gid, ok := c.boxes[r.ID]
+ if !ok {
+ return fmt.Errorf("gamedata: unknown cash random box %d", r.ID)
+ }
+ raw, ok := c.groups[gid]
+ if !ok {
+ return fmt.Errorf("gamedata: unknown cash reward group %d", gid)
+ }
+ drop, err := optionalScalar(raw, 2)
+ if err != nil {
+ return err
+ }
+ if drop == 0 {
+ selected, err := c.graph.Resolve([]BattleReward{r})
+ if err != nil {
+ return err
+ }
+ out = append(out, selected...)
+ return nil
+ }
+ if drop != 1 {
+ return fmt.Errorf("gamedata: unsupported cash reward mode %d", drop)
+ }
+ children, err := eventGameRewards(raw, 6, 5, 4)
+ if err != nil || len(children) == 0 {
+ return fmt.Errorf("gamedata: malformed cash combination")
+ }
+ weights, err := packedInts(raw, 8)
+ if err != nil || len(weights) != len(children) {
+ return fmt.Errorf("gamedata: malformed cash combination ratios")
+ }
+ visiting[r.ID] = true
+ defer delete(visiting, r.ID)
+ for i, child := range children {
+ if weights[i] != 1 && weights[i] != 100 {
+ return fmt.Errorf("gamedata: cash combination ratio %d is not guaranteed", weights[i])
+ }
+ if child.Count > math.MaxInt32/r.Count {
+ return fmt.Errorf("gamedata: cash reward overflow")
+ }
+ child.Count *= r.Count
+ if err = emit(child, false); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+ for _, r := range rewards {
+ if err := emit(r, true); err != nil {
+ return nil, err
+ }
+ }
+ return out, nil
+}
diff --git a/go/internal/server/gamedata/cash_rewards_test.go b/go/internal/server/gamedata/cash_rewards_test.go
new file mode 100644
index 0000000..c59f226
--- /dev/null
+++ b/go/internal/server/gamedata/cash_rewards_test.go
@@ -0,0 +1,41 @@
+package gamedata
+
+import (
+ "bd2server/internal/server/wire"
+ "testing"
+)
+
+func TestCashCombinationsGuaranteeOneAndHundredPreserveManualBoxes(t *testing.T) {
+ raw := wire.AppendVarint(nil, 2, 1)
+ for _, f := range []struct {
+ n int
+ v uint64
+ }{{6, 3}, {6, 8}, {5, 0}, {5, 1000}, {4, 60}, {4, 2}, {8, 1}, {8, 100}} {
+ raw = wire.AppendVarint(raw, f.n, f.v)
+ }
+ c := &CashRewardResolver{boxes: map[uint64]uint64{1: 10}, direct: map[uint64]bool{1: true}, groups: map[uint64][]byte{10: raw}}
+ got, err := c.ResolveGranted([]BattleReward{{Type: 9, ID: 1, Count: 2}, {Type: 8, ID: 2, Count: 1}})
+ if err != nil || len(got) != 3 || got[0].Type != 3 || got[0].Count != 120 || got[1].Count != 4 || got[2].ID != 2 || got[2].Type != 8 {
+ t.Fatalf("cash rewards %+v %v", got, err)
+ }
+ raw = wire.AppendVarint(raw, 8, 35)
+ c.groups[10] = raw
+ if _, err = c.ResolveGranted([]BattleReward{{Type: 9, ID: 1, Count: 1}}); err == nil {
+ t.Fatal("malformed ratio accepted")
+ }
+}
+
+func TestLoginPassTopLevelWrapperExpandsAndNestedManualGiftStaysOwned(t *testing.T) {
+ raw := wire.AppendVarint(nil, 2, 1)
+ for _, f := range []struct {
+ n int
+ v uint64
+ }{{6, 3}, {6, 9}, {5, 0}, {5, 200}, {4, 125}, {4, 1}, {8, 1}, {8, 100}} {
+ raw = wire.AppendVarint(raw, f.n, f.v)
+ }
+ c := &CashRewardResolver{boxes: map[uint64]uint64{100: 1000, 200: 2000}, direct: map[uint64]bool{}, groups: map[uint64][]byte{1000: raw}}
+ got, err := c.ResolveGranted([]BattleReward{{Type: 9, ID: 100, Count: 1}})
+ if err != nil || len(got) != 2 || got[0].Type != 3 || got[0].Count != 125 || got[1].Type != 9 || got[1].ID != 200 {
+ t.Fatalf("login pass wrapper=%+v err=%v", got, err)
+ }
+}
diff --git a/go/internal/server/gamedata/char_awake.go b/go/internal/server/gamedata/char_awake.go
index 4e6670c..f28632a 100644
--- a/go/internal/server/gamedata/char_awake.go
+++ b/go/internal/server/gamedata/char_awake.go
@@ -4,8 +4,6 @@ import (
"database/sql"
"fmt"
"math"
- "os"
- "path/filepath"
)
// CharAwakeCost is one exact CharAwakeGrowthTable material row. Type 4 is
@@ -49,24 +47,11 @@ type CharImprintTarget struct {
}
func LoadCharAwakeDesign(root, version string) (*CharAwakeDesign, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-char-awake-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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()
return loadCharAwakeDesign(db)
}
@@ -199,13 +184,28 @@ func loadCharAwakeDesign(db *sql.DB) (*CharAwakeDesign, error) {
if err != nil {
return nil, err
}
+ type stageRow struct {
+ id uint64
+ proto []byte
+ }
+ var sourceRows []stageRow
for rows.Next() {
- var id uint64
- var proto []byte
- if err := rows.Scan(&id, &proto); err != nil {
+ var row stageRow
+ if err := rows.Scan(&row.id, &row.proto); err != nil {
rows.Close()
return nil, err
}
+ sourceRows = append(sourceRows, row)
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ for _, row := range sourceRows {
+ id, proto := row.id, row.proto
growthID, _ := packedInts(proto, 1)
grade, _ := packedInts(proto, 9)
growthGrade, _ := packedInts(proto, 10)
diff --git a/go/internal/server/gamedata/clear_packages.go b/go/internal/server/gamedata/clear_packages.go
new file mode 100644
index 0000000..4694516
--- /dev/null
+++ b/go/internal/server/gamedata/clear_packages.go
@@ -0,0 +1,56 @@
+package gamedata
+
+import "fmt"
+
+// ClearPackageRewardDesign matches ClearPackTable and ClearEvilCastleTable.
+// Kind is the wire enum: zero story pack, one evil-castle tower.
+type ClearPackageRewardDesign struct{ Kind, GroupID, TicketID, TargetID, Level, RandomBoxID, Type uint64 }
+type ClearPackageCatalog struct{ Rewards []ClearPackageRewardDesign }
+
+func LoadClearPackageCatalog(root, version string) (*ClearPackageCatalog, error) {
+ db, done, err := openStatDatabase(root, version)
+ if err != nil {
+ return nil, err
+ }
+ defer done()
+ c := &ClearPackageCatalog{}
+ for kind, table := range []string{"ClearPackTable", "ClearEvilCastleTable"} {
+ rows, err := db.Query("SELECT ProtoBuf FROM " + table)
+ if err != nil {
+ return nil, err
+ }
+ for rows.Next() {
+ var raw []byte
+ if err = rows.Scan(&raw); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ v, err := cashScalars(raw, 1, 2, 3, 4, 5, 6)
+ if err != nil {
+ rows.Close()
+ return nil, err
+ }
+ r := ClearPackageRewardDesign{Kind: uint64(kind), TicketID: v[0], GroupID: v[1], Type: v[5]}
+ if kind == 0 {
+ r.TargetID = v[2]
+ r.Level = v[3]
+ r.RandomBoxID = v[4]
+ } else {
+ r.RandomBoxID = v[2]
+ r.Level = v[3]
+ r.TargetID = v[4]
+ }
+ if r.GroupID == 0 || r.TicketID == 0 || r.TargetID == 0 || r.RandomBoxID == 0 || r.Type > 1 {
+ rows.Close()
+ return nil, fmt.Errorf("gamedata: invalid clear-package reward")
+ }
+ c.Rewards = append(c.Rewards, r)
+ }
+ err = rows.Err()
+ rows.Close()
+ if err != nil {
+ return nil, err
+ }
+ }
+ return c, nil
+}
diff --git a/go/internal/server/gamedata/costume_potential.go b/go/internal/server/gamedata/costume_potential.go
index a61b4d8..f23949d 100644
--- a/go/internal/server/gamedata/costume_potential.go
+++ b/go/internal/server/gamedata/costume_potential.go
@@ -4,8 +4,6 @@ import (
"database/sql"
"fmt"
"math"
- "os"
- "path/filepath"
)
type CostumePotentialCost struct{ Type, ID, Count uint64 }
@@ -28,24 +26,11 @@ type CostumePotentialDesign struct {
}
func LoadCostumePotentialDesign(root, version string) (*CostumePotentialDesign, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-costume-potential-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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()
return loadCostumePotentialDesign(db)
}
diff --git a/go/internal/server/gamedata/database_cache.go b/go/internal/server/gamedata/database_cache.go
new file mode 100644
index 0000000..e9285cd
--- /dev/null
+++ b/go/internal/server/gamedata/database_cache.go
@@ -0,0 +1,310 @@
+package gamedata
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+const DefaultDatabaseCacheBytes int64 = 512 << 20
+
+type databaseCacheKey struct {
+ Path, Version, Logical string
+ Size, Modified int64
+}
+type databaseCacheEntry struct {
+ db *sql.DB
+ size int64
+ refs int
+ used uint64
+ ready chan struct{}
+ err error
+ retired bool
+}
+
+// DatabaseCache owns read-only SQLite connections. A lease must be released
+// after closing all rows; callers must never Close the returned shared DB.
+// The budget bounds retained SQLite images, excluding transient decrypt buffers
+// and SQLite page-cache overhead. Active leases may temporarily exceed it.
+type DatabaseCache struct {
+ mu sync.Mutex
+ loadMu sync.Mutex
+ entries map[databaseCacheKey]*databaseCacheEntry
+ budget, bytes int64
+ clock uint64
+ closed bool
+ loader func(string, string, string) ([]byte, error)
+}
+
+func NewDatabaseCache(maxBytes int64) *DatabaseCache {
+ if maxBytes <= 0 {
+ maxBytes = DefaultDatabaseCacheBytes
+ }
+ return &DatabaseCache{budget: maxBytes, entries: map[databaseCacheKey]*databaseCacheEntry{}, loader: ReadDatabase}
+}
+
+var sharedDatabaseCache = NewDatabaseCache(DefaultDatabaseCacheBytes)
+var sharedDatabaseCacheMu sync.RWMutex
+var databaseMemoryID atomic.Uint64
+
+func databaseKey(root, version, logical string) (databaseCacheKey, error) {
+ if _, err := DatabaseName(logical); err != nil {
+ return databaseCacheKey{}, err
+ }
+ if version == "" || version == "." || version == ".." || strings.ContainsAny(version, `/\`) {
+ return databaseCacheKey{}, fmt.Errorf("gamedata: invalid database version")
+ }
+ abs, err := filepath.Abs(filepath.Clean(root))
+ if err != nil {
+ return databaseCacheKey{}, err
+ }
+ abs, err = filepath.EvalSymlinks(abs)
+ if err != nil {
+ return databaseCacheKey{}, err
+ }
+ archive := filepath.Join(abs, version, "release", ArchiveName)
+ stat, err := os.Stat(archive)
+ if err != nil {
+ return databaseCacheKey{}, err
+ }
+ path := archive
+ if runtime.GOOS == "windows" {
+ path = strings.ToLower(path)
+ }
+ return databaseCacheKey{Path: path, Version: version, Logical: logical, Size: stat.Size(), Modified: stat.ModTime().UnixNano()}, nil
+}
+
+func memoryDatabase(plain []byte) (*sql.DB, error) {
+ db, err := sql.Open("sqlite", fmt.Sprintf("file:bd2-gamedata-%d?mode=memory&cache=private", databaseMemoryID.Add(1)))
+ if err != nil {
+ return nil, err
+ }
+ db.SetMaxOpenConns(1)
+ db.SetMaxIdleConns(1)
+ conn, err := db.Conn(context.Background())
+ if err != nil {
+ db.Close()
+ return nil, err
+ }
+ err = conn.Raw(func(driver any) error {
+ d, ok := driver.(interface{ Deserialize([]byte) error })
+ if !ok {
+ return fmt.Errorf("gamedata: SQLite driver lacks Deserialize")
+ }
+ return d.Deserialize(plain)
+ })
+ if err == nil {
+ _, err = conn.ExecContext(context.Background(), "PRAGMA query_only=ON")
+ }
+ if err == nil {
+ _, err = conn.ExecContext(context.Background(), "PRAGMA cache_size=-4096")
+ }
+ if err == nil {
+ var n int
+ err = conn.QueryRowContext(context.Background(), "SELECT count(*) FROM sqlite_schema").Scan(&n)
+ }
+ closeErr := conn.Close()
+ if err == nil {
+ err = closeErr
+ }
+ if err != nil {
+ db.Close()
+ return nil, err
+ }
+ return db, nil
+}
+
+func (c *DatabaseCache) Open(root, version, logical string) (*sql.DB, func(), error) {
+ key, err := databaseKey(root, version, logical)
+ if err != nil {
+ return nil, nil, err
+ }
+ for {
+ c.mu.Lock()
+ if c.closed {
+ c.mu.Unlock()
+ return nil, nil, fmt.Errorf("gamedata: database cache closed")
+ }
+ if e, ok := c.entries[key]; ok {
+ if e.ready != nil {
+ ready := e.ready
+ c.mu.Unlock()
+ <-ready
+ if e.err != nil {
+ return nil, nil, e.err
+ }
+ continue
+ }
+ e.refs++
+ c.clock++
+ e.used = c.clock
+ c.mu.Unlock()
+ return e.db, c.release(e), nil
+ }
+ e := &databaseCacheEntry{ready: make(chan struct{})}
+ c.entries[key] = e
+ c.mu.Unlock()
+ // Serial loading limits peak encrypted + plaintext + SQLite image memory.
+ c.loadMu.Lock()
+ loadStarted := time.Now()
+ plain, loadErr := c.loader(root, version, logical)
+ var db *sql.DB
+ size := int64(len(plain))
+ if loadErr == nil {
+ current, e2 := databaseKey(root, version, logical)
+ if e2 != nil {
+ loadErr = e2
+ } else if current != key {
+ loadErr = fmt.Errorf("gamedata: archive changed during database load")
+ } else {
+ db, loadErr = memoryDatabase(plain)
+ }
+ }
+ plain = nil
+ c.loadMu.Unlock()
+ if loadErr == nil {
+ slog.Debug("GameData database cached", "logical", logical, "version", version, "image_bytes", size, "load_ms", float64(time.Since(loadStarted).Microseconds())/1000)
+ }
+ c.mu.Lock()
+ e.err = loadErr
+ ready := e.ready
+ e.ready = nil
+ if loadErr != nil || c.closed {
+ delete(c.entries, key)
+ if db != nil {
+ _ = db.Close()
+ }
+ if loadErr == nil {
+ e.err = fmt.Errorf("gamedata: database cache closed")
+ }
+ close(ready)
+ c.mu.Unlock()
+ return nil, nil, e.err
+ }
+ e.db = db
+ e.size = size
+ e.refs = 1
+ c.clock++
+ e.used = c.clock
+ c.bytes += size
+ // Retire earlier versions of a replaced archive. Existing leases remain
+ // valid until released, but cannot become a new request's database.
+ for oldKey, old := range c.entries {
+ if oldKey != key && oldKey.Path == key.Path && oldKey.Logical == key.Logical && old.ready == nil {
+ delete(c.entries, oldKey)
+ old.retired = true
+ if old.refs == 0 {
+ c.bytes -= old.size
+ _ = old.db.Close()
+ }
+ }
+ }
+ c.evictLocked()
+ close(ready)
+ c.mu.Unlock()
+ return db, c.release(e), nil
+ }
+}
+func (c *DatabaseCache) release(e *databaseCacheEntry) func() {
+ var once sync.Once
+ return func() {
+ once.Do(func() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ e.refs--
+ if e.refs == 0 && e.retired {
+ c.bytes -= e.size
+ _ = e.db.Close()
+ }
+ c.evictLocked()
+ })
+ }
+}
+func (c *DatabaseCache) evictLocked() {
+ for c.bytes > c.budget {
+ var key databaseCacheKey
+ var oldest *databaseCacheEntry
+ for k, e := range c.entries {
+ if e.ready == nil && e.refs == 0 && (oldest == nil || e.used < oldest.used) {
+ key = k
+ oldest = e
+ }
+ }
+ if oldest == nil {
+ return
+ }
+ delete(c.entries, key)
+ c.bytes -= oldest.size
+ oldest.retired = true
+ _ = oldest.db.Close()
+ }
+}
+
+// Close retires all images; active queries finish before their final release.
+func (c *DatabaseCache) Close() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.closed = true
+ var first error
+ for key, e := range c.entries {
+ if e.ready != nil {
+ continue
+ }
+ delete(c.entries, key)
+ e.retired = true
+ if e.refs == 0 {
+ c.bytes -= e.size
+ if err := e.db.Close(); err != nil && first == nil {
+ first = err
+ }
+ }
+ }
+ return first
+}
+func (c *DatabaseCache) WithDatabase(root, version, logical string, query func(*sql.DB) error) error {
+ if query == nil {
+ return fmt.Errorf("gamedata: missing query callback")
+ }
+ db, release, err := c.Open(root, version, logical)
+ if err != nil {
+ return err
+ }
+ defer release()
+ return query(db)
+}
+func OpenDatabase(root, version, logical string) (*sql.DB, func(), error) {
+ sharedDatabaseCacheMu.RLock()
+ defer sharedDatabaseCacheMu.RUnlock()
+ return sharedDatabaseCache.Open(root, version, logical)
+}
+func WithDatabase(root, version, logical string, query func(*sql.DB) error) error {
+ if query == nil {
+ return fmt.Errorf("gamedata: missing query callback")
+ }
+ db, release, err := OpenDatabase(root, version, logical)
+ if err != nil {
+ return err
+ }
+ defer release()
+ return query(db)
+}
+
+// CloseDatabaseCache retires the current generation. Active leases finish
+// normally; the next OpenDatabase starts with a fresh empty cache.
+func CloseDatabaseCache() error {
+ sharedDatabaseCacheMu.Lock()
+ old := sharedDatabaseCache
+ sharedDatabaseCache = NewDatabaseCache(DefaultDatabaseCacheBytes)
+ sharedDatabaseCacheMu.Unlock()
+ return old.Close()
+}
diff --git a/go/internal/server/gamedata/database_cache_test.go b/go/internal/server/gamedata/database_cache_test.go
new file mode 100644
index 0000000..5f03b69
--- /dev/null
+++ b/go/internal/server/gamedata/database_cache_test.go
@@ -0,0 +1,322 @@
+package gamedata
+
+import (
+ "archive/zip"
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "sync/atomic"
+ "testing"
+)
+
+func cachePlain(t *testing.T, value int) []byte {
+ t.Helper()
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ if _, err = db.Exec(fmt.Sprintf("CREATE TABLE test(value INTEGER);INSERT INTO test VALUES(%d)", value)); err != nil {
+ t.Fatal(err)
+ }
+ conn, err := db.Conn(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer conn.Close()
+ var raw []byte
+ err = conn.Raw(func(v any) error {
+ var e error
+ raw, e = v.(interface{ Serialize() ([]byte, error) }).Serialize()
+ return e
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return raw
+}
+func cacheArchive(t *testing.T, root string, marker byte) {
+ t.Helper()
+ dir := filepath.Join(root, "v1", "release")
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(dir, ArchiveName)
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ w := zip.NewWriter(f)
+ for _, logical := range []string{"common", "pack1", "pack2"} {
+ name, _ := DatabaseName(logical)
+ entry, err := w.Create(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = entry.Write([]byte{marker}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err = w.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err = f.Close(); err != nil {
+ t.Fatal(err)
+ }
+}
+func TestDatabaseCacheSingleFlightReadOnlyAndLifetime(t *testing.T) {
+ root := t.TempDir()
+ cacheArchive(t, root, 1)
+ plain := cachePlain(t, 7)
+ c := NewDatabaseCache(1 << 20)
+ defer c.Close()
+ var loads atomic.Int32
+ c.loader = func(string, string, string) ([]byte, error) { loads.Add(1); return plain, nil }
+ var wg sync.WaitGroup
+ for i := 0; i < 12; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ err := c.WithDatabase(root, "v1", "common", func(db *sql.DB) error {
+ var n int
+ if err := db.QueryRow("SELECT value FROM test").Scan(&n); err != nil {
+ return err
+ }
+ if n != 7 {
+ return fmt.Errorf("wrong value")
+ }
+ if _, err := db.Exec("INSERT INTO test VALUES(8)"); err == nil {
+ return fmt.Errorf("write accepted")
+ }
+ return nil
+ })
+ if err != nil {
+ t.Error(err)
+ }
+ }()
+ }
+ wg.Wait()
+ if loads.Load() != 1 {
+ t.Fatal("duplicate decrypt", loads.Load())
+ }
+ db, release, err := c.Open(root, "v1", "common")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = c.Close(); err != nil {
+ t.Fatal(err)
+ }
+ var n int
+ if err = db.QueryRow("SELECT value FROM test").Scan(&n); err != nil {
+ t.Fatal("active lease closed", err)
+ }
+ release()
+ release()
+ if err = db.Ping(); err == nil {
+ t.Fatal("released retired database alive")
+ }
+}
+func TestDatabaseCacheFailureRetryReplacementAndEviction(t *testing.T) {
+ root := t.TempDir()
+ cacheArchive(t, root, 1)
+ plain := cachePlain(t, 1)
+ c := NewDatabaseCache(int64(len(plain)))
+ defer c.Close()
+ var loads int
+ c.loader = func(string, string, string) ([]byte, error) {
+ loads++
+ if loads == 1 {
+ return nil, fmt.Errorf("failure")
+ }
+ return plain, nil
+ }
+ if _, _, err := c.Open(root, "v1", "common"); err == nil {
+ t.Fatal("failure missing")
+ }
+ first, release, err := c.Open(root, "v1", "common")
+ if err != nil {
+ t.Fatal(err)
+ }
+ release()
+ second, done, err := c.Open(filepath.Join(root, "."), "v1", "common")
+ if err != nil || first != second || loads != 2 {
+ t.Fatal("canonical hit failed", err, loads)
+ }
+ done()
+ _, done, err = c.Open(root, "v1", "pack1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ if err = first.Ping(); err == nil {
+ t.Fatal("LRU not evicted")
+ }
+ old, oldRelease, err := c.Open(root, "v1", "pack1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cacheArchive(t, root, 2)
+ plain = cachePlain(t, 9)
+ current, currentRelease, err := c.Open(root, "v1", "pack1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if old == current {
+ t.Fatal("replacement reused stale image")
+ }
+ var n int
+ if err = current.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 9 {
+ t.Fatal("replacement wrong", n, err)
+ }
+ oldRelease()
+ currentRelease()
+}
+
+func TestDatabaseCacheMutationDuringLoadFailsAndDoesNotPoison(t *testing.T) {
+ root := t.TempDir()
+ cacheArchive(t, root, 1)
+ plain := cachePlain(t, 3)
+ c := NewDatabaseCache(1 << 20)
+ defer c.Close()
+ calls := 0
+ c.loader = func(string, string, string) ([]byte, error) {
+ calls++
+ if calls == 1 {
+ cacheArchive(t, root, 2)
+ }
+ return plain, nil
+ }
+ if _, _, err := c.Open(root, "v1", "common"); err == nil {
+ t.Fatal("replaced archive was cached")
+ }
+ db, release, err := c.Open(root, "v1", "common")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ var n int
+ if err = db.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 3 || calls != 2 {
+ t.Fatal(n, calls, err)
+ }
+}
+
+func TestSharedDatabaseCacheCloseStartsFreshGeneration(t *testing.T) {
+ root := t.TempDir()
+ cacheArchive(t, root, 1)
+ plain := cachePlain(t, 11)
+ sharedDatabaseCacheMu.Lock()
+ previous := sharedDatabaseCache
+ current := NewDatabaseCache(1 << 20)
+ current.loader = func(string, string, string) ([]byte, error) { return plain, nil }
+ sharedDatabaseCache = current
+ sharedDatabaseCacheMu.Unlock()
+ t.Cleanup(func() {
+ _ = CloseDatabaseCache()
+ sharedDatabaseCacheMu.Lock()
+ empty := sharedDatabaseCache
+ sharedDatabaseCache = previous
+ sharedDatabaseCacheMu.Unlock()
+ _ = empty.Close()
+ })
+ old, releaseOld, err := OpenDatabase(root, "v1", "common")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer releaseOld()
+ if err = CloseDatabaseCache(); err != nil {
+ t.Fatal(err)
+ }
+ sharedDatabaseCacheMu.Lock()
+ fresh := sharedDatabaseCache
+ fresh.loader = func(string, string, string) ([]byte, error) { return plain, nil }
+ sharedDatabaseCacheMu.Unlock()
+ if fresh == current {
+ t.Fatal("global generation not replaced")
+ }
+ var n int
+ if err = old.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 11 {
+ t.Fatal("retired active lease unavailable", n, err)
+ }
+ newDB, releaseNew, err := OpenDatabase(root, "v1", "common")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer releaseNew()
+ if newDB == old {
+ t.Fatal("new generation reused old handle")
+ }
+ if err = newDB.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 11 {
+ t.Fatal(n, err)
+ }
+ releaseOld()
+ if err = old.Ping(); err == nil {
+ t.Fatal("retired released generation retained database")
+ }
+}
+
+func TestDatabaseCacheRootAndVersionIsolation(t *testing.T) {
+ rootA, rootB := t.TempDir(), t.TempDir()
+ cacheArchive(t, rootA, 1)
+ cacheArchive(t, rootB, 1)
+ archive, err := os.ReadFile(filepath.Join(rootA, "v1", "release", ArchiveName))
+ if err != nil {
+ t.Fatal(err)
+ }
+ v2dir := filepath.Join(rootA, "v2", "release")
+ if err = os.MkdirAll(v2dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err = os.WriteFile(filepath.Join(v2dir, ArchiveName), archive, 0600); err != nil {
+ t.Fatal(err)
+ }
+ plainA, plainB, plainV2 := cachePlain(t, 1), cachePlain(t, 2), cachePlain(t, 3)
+ c := NewDatabaseCache(1 << 20)
+ defer c.Close()
+ loads := 0
+ c.loader = func(root, version, _ string) ([]byte, error) {
+ loads++
+ if version == "v2" {
+ return plainV2, nil
+ }
+ if root == rootB {
+ return plainB, nil
+ }
+ return plainA, nil
+ }
+ type item struct {
+ root, version string
+ want int
+ }
+ handles := map[*sql.DB]bool{}
+ for _, input := range []item{{rootA, "v1", 1}, {rootB, "v1", 2}, {rootA, "v2", 3}} {
+ db, release, err := c.Open(input.root, input.version, "common")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var value int
+ if err = db.QueryRow("SELECT value FROM test").Scan(&value); err != nil || value != input.want {
+ release()
+ t.Fatal("root/version crossed", value, input.want, err)
+ }
+ if handles[db] {
+ release()
+ t.Fatal("isolated inputs shared handle")
+ }
+ handles[db] = true
+ release()
+ }
+ if loads != 3 {
+ t.Fatal("isolated inputs did not load separately", loads)
+ }
+ for _, input := range []item{{rootA, "v1", 1}, {rootB, "v1", 2}, {rootA, "v2", 3}} {
+ if err = c.WithDatabase(input.root, input.version, "common", func(db *sql.DB) error { return db.Ping() }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if loads != 3 {
+ t.Fatal("isolated caches did not hit", loads)
+ }
+}
diff --git a/go/internal/server/gamedata/dispatch.go b/go/internal/server/gamedata/dispatch.go
index 3623681..c33362a 100644
--- a/go/internal/server/gamedata/dispatch.go
+++ b/go/internal/server/gamedata/dispatch.go
@@ -187,6 +187,19 @@ func (d *DispatchDesign) Roll(count uint64, draw func(uint64) (uint64, error)) (
budget--
if g.Drop == 1 {
for _, e := range g.Entries {
+ if e.Weight > 100 {
+ return fmt.Errorf("gamedata: direct dispatch percent exceeds 100")
+ }
+ v, err := draw(100)
+ if err != nil {
+ return err
+ }
+ if v >= 100 {
+ return fmt.Errorf("gamedata: sampler out of range")
+ }
+ if v >= e.Weight {
+ continue
+ }
if err := emit(e); err != nil {
return err
}
diff --git a/go/internal/server/gamedata/dispatch_test.go b/go/internal/server/gamedata/dispatch_test.go
index 994436c..735c5be 100644
--- a/go/internal/server/gamedata/dispatch_test.go
+++ b/go/internal/server/gamedata/dispatch_test.go
@@ -6,7 +6,7 @@ import (
)
func TestDispatchWeightedNestedRewards(t *testing.T) {
- leaf := &dispatchGroup{Drop: 1, Entries: []dispatchEntry{{Reward: BattleReward{Type: 8, ID: 44, Count: 3}}}}
+ leaf := &dispatchGroup{Drop: 1, Entries: []dispatchEntry{{Weight: 100, Reward: BattleReward{Type: 8, ID: 44, Count: 3}}}}
weighted := &dispatchGroup{Drop: 0, Count: 2, Entries: []dispatchEntry{{Reward: BattleReward{Type: 9, Count: 1}, Weight: 90, Child: leaf}, {Reward: BattleReward{Type: 4, Count: 5}, Weight: 10}}}
d := &DispatchDesign{Rewards: []BattleReward{{Type: 9, ID: 7, Count: 1}}, boxes: map[uint64]*dispatchGroup{7: weighted}}
draws := 0
@@ -15,7 +15,7 @@ func TestDispatchWeightedNestedRewards(t *testing.T) {
return 0, fmt.Errorf("limit %d", n)
}
draws++
- if draws%2 == 0 {
+ if draws%3 == 0 {
return 95, nil
}
return 0, nil
@@ -27,7 +27,7 @@ func TestDispatchWeightedNestedRewards(t *testing.T) {
for _, v := range r {
totals[v.Type] += v.Count
}
- if totals[8] != 6 || totals[4] != 10 || draws != 4 {
+ if totals[8] != 6 || totals[4] != 10 || draws != 6 {
t.Fatalf("rewards %+v draws %d", r, draws)
}
if _, err = d.Roll(1, func(n uint64) (uint64, error) { return n, nil }); err == nil {
diff --git a/go/internal/server/gamedata/equipment_collaboration.go b/go/internal/server/gamedata/equipment_collaboration.go
index 340d91d..aad3e92 100644
--- a/go/internal/server/gamedata/equipment_collaboration.go
+++ b/go/internal/server/gamedata/equipment_collaboration.go
@@ -3,32 +3,17 @@ package gamedata
import (
"database/sql"
"fmt"
- "os"
- "path/filepath"
"sort"
)
// IncludeCollaborationURWeapons extends only the UR equipment ticket pool.
// Limited costumes identify eligible owners; equipment IDs remain version data.
func (c *EquipmentGachaCatalog) IncludeCollaborationURWeapons(root, version string) error {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return err
}
- dir, err := os.MkdirTemp("", "bd2-collaboration-equipment-")
- if err != nil {
- return err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.db")
- if err := os.WriteFile(path, plain, 0o600); err != nil {
- return err
- }
- db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
- if err != nil {
- return err
- }
- defer db.Close()
+ defer release()
return c.includeCollaborationURWeapons(db)
}
diff --git a/go/internal/server/gamedata/equipment_craft.go b/go/internal/server/gamedata/equipment_craft.go
index c6c754f..3d5fe03 100644
--- a/go/internal/server/gamedata/equipment_craft.go
+++ b/go/internal/server/gamedata/equipment_craft.go
@@ -61,13 +61,30 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) {
if err != nil {
return nil, err
}
+ type makingRow struct {
+ id uint64
+ raw []byte
+ }
+ var makingRows []makingRow
for rows.Next() {
- var id uint64
- var raw []byte
- if err := rows.Scan(&id, &raw); err != nil {
+ var row makingRow
+ if err := rows.Scan(&row.id, &row.raw); err != nil {
rows.Close()
return nil, err
}
+ makingRows = append(makingRows, row)
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ // Recipes perform nested random-box, reward and equipment queries only
+ // after the source result set releases the single database connection.
+ for _, row := range makingRows {
+ id, raw := row.id, row.raw
counts, _ := packedInts(raw, 4)
materialIDs, _ := packedInts(raw, 5)
materialTypes, _ := packedInts(raw, 6)
@@ -78,45 +95,32 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) {
if len(counts) == 0 || len(counts) != len(materialIDs) || len(counts) != len(materialTypes) ||
len(resultCounts) != 1 || resultCounts[0] != 1 || len(boxIDs) != 1 ||
len(resultTypes) != 1 || resultTypes[0] != 9 || len(talentLevels) != 1 || talentLevels[0] == 0 {
- rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d malformed", id)
}
var box []byte
if err := db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", boxIDs[0]).Scan(&box); err != nil {
- rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d random box: %w", id, err)
}
groups, _ := packedInts(box, 9)
if len(groups) != 1 || groups[0] == 0 {
- rows.Close()
return nil, fmt.Errorf("gamedata: equipment making random box %d malformed", boxIDs[0])
}
results, equipmentOnly, err := classifyEquipmentRewardPool(db, groups[0])
if err != nil || !equipmentOnly || len(results) == 0 {
- rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d reward group: %w", id, err)
}
recipe := EquipmentCraftRecipe{ID: id, ResultCount: resultCounts[0], TalentLevel: talentLevels[0], Results: results}
for i := range counts {
if counts[i] == 0 || materialIDs[i] == 0 || materialTypes[i] == 0 {
- rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d invalid material", id)
}
recipe.Costs = append(recipe.Costs, PromotionCost{Type: materialTypes[i], ID: materialIDs[i], Count: counts[i]})
}
if err := design.loadEquipmentPool(db, results); err != nil {
- rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d: %w", id, err)
}
design.Recipes[id] = recipe
}
- if err := rows.Err(); err != nil {
- rows.Close()
- return nil, err
- }
- if err := rows.Close(); err != nil {
- return nil, err
- }
talents := make(map[uint64]equipmentMakingTalent)
rows, err = db.Query("SELECT id,ProtoBuf FROM TalentTable")
diff --git a/go/internal/server/gamedata/equipment_gacha.go b/go/internal/server/gamedata/equipment_gacha.go
index 4cae8a2..a3de3bc 100644
--- a/go/internal/server/gamedata/equipment_gacha.go
+++ b/go/internal/server/gamedata/equipment_gacha.go
@@ -7,8 +7,6 @@ import (
"fmt"
"math"
"math/big"
- "os"
- "path/filepath"
"sort"
)
@@ -62,24 +60,11 @@ type EquipmentOptionChoice struct{ GroupID, ID uint64 }
// LoadEquipmentGachaGroups loads only groups selected by a captured dynamic
// schedule. Presence in GameData alone does not mean a banner is open.
func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*EquipmentGachaCatalog, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-equipment-gacha-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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()
c := &EquipmentGachaCatalog{Gachas: map[uint64]EquipmentGacha{}, groups: map[uint64]EquipmentGachaGroup{}, byGacha: map[uint64]uint64{}, equipment: map[uint64]EquipmentDesign{}}
for _, groupID := range groupIDs {
var raw []byte
@@ -146,6 +131,11 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme
if err != nil {
return nil, err
}
+ type ticketCandidate struct {
+ id, count, reward uint64
+ tickets []uint64
+ }
+ var candidates []ticketCandidate
for rows.Next() {
var id uint64
var raw []byte
@@ -164,7 +154,18 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme
if len(count) != 1 || count[0] == 0 || len(reward) != 1 || len(tickets) == 0 || len(price) != 0 || len(kind) != 0 {
continue
}
- pool, equipmentOnly, err := classifyEquipmentRewardPool(db, reward[0])
+ candidates = append(candidates, ticketCandidate{id, count[0], reward[0], append([]uint64(nil), tickets...)})
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ for _, candidate := range candidates {
+ id := candidate.id
+ pool, equipmentOnly, err := classifyEquipmentRewardPool(db, candidate.reward)
if err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: ticket gacha %d: %w", id, err)
@@ -172,7 +173,7 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme
if !equipmentOnly {
continue
}
- g := EquipmentGacha{ID: id, Count: int(count[0]), TicketIDs: append([]uint64(nil), tickets...), Pool: pool, TicketOnly: true}
+ g := EquipmentGacha{ID: id, Count: int(candidate.count), TicketIDs: candidate.tickets, Pool: pool, TicketOnly: true}
c.Gachas[id] = g
for _, item := range pool {
if err := c.loadEquipmentTree(db, item); err != nil {
diff --git a/go/internal/server/gamedata/equipment_option_reroll.go b/go/internal/server/gamedata/equipment_option_reroll.go
index 9dfd028..2ec4932 100644
--- a/go/internal/server/gamedata/equipment_option_reroll.go
+++ b/go/internal/server/gamedata/equipment_option_reroll.go
@@ -4,8 +4,6 @@ import (
"database/sql"
"fmt"
"math"
- "os"
- "path/filepath"
)
// EquipmentOptionRerollDesign is the static equipment-option refinement
@@ -57,24 +55,11 @@ type EquipmentOptionRerollRoll struct {
}
func LoadEquipmentOptionRerollDesign(root, version string) (*EquipmentOptionRerollDesign, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-equipment-option-reroll-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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()
return loadEquipmentOptionRerollDesign(db)
}
diff --git a/go/internal/server/gamedata/equipment_slots.go b/go/internal/server/gamedata/equipment_slots.go
index 4c92232..2408c78 100644
--- a/go/internal/server/gamedata/equipment_slots.go
+++ b/go/internal/server/gamedata/equipment_slots.go
@@ -3,31 +3,16 @@ package gamedata
import (
"database/sql"
"fmt"
- "os"
- "path/filepath"
)
// LoadEquipmentSlots reads EquipmentTable.SlotType for every installed item.
// SlotType is a proto3 enum, so an absent field is the valid first slot (0).
func LoadEquipmentSlots(root, version string) (map[uint64]uint64, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-equipment-slots-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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()
return loadEquipmentSlots(db)
}
diff --git a/go/internal/server/gamedata/equipment_smelting.go b/go/internal/server/gamedata/equipment_smelting.go
index 21ace75..34194c2 100644
--- a/go/internal/server/gamedata/equipment_smelting.go
+++ b/go/internal/server/gamedata/equipment_smelting.go
@@ -4,8 +4,6 @@ import (
"database/sql"
"fmt"
"math"
- "os"
- "path/filepath"
)
// EquipmentSmeltingDesign contains only static 2.35.10 GameData facts. It does
@@ -37,24 +35,11 @@ type EquipmentSmeltingMileage struct {
}
func LoadEquipmentSmeltingDesign(root, version string) (*EquipmentSmeltingDesign, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-equipment-smelting-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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()
return loadEquipmentSmeltingDesign(db)
}
diff --git a/go/internal/server/gamedata/equipment_upgrade.go b/go/internal/server/gamedata/equipment_upgrade.go
index b75d1ce..2940cd4 100644
--- a/go/internal/server/gamedata/equipment_upgrade.go
+++ b/go/internal/server/gamedata/equipment_upgrade.go
@@ -6,8 +6,6 @@ import (
"encoding/binary"
"fmt"
"math"
- "os"
- "path/filepath"
)
type EquipmentUpgradeLevel struct {
@@ -29,24 +27,11 @@ type EquipmentUpgradeDesign struct {
}
func LoadEquipmentUpgradeDesign(root, version string) (*EquipmentUpgradeDesign, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-equipment-upgrade-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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()
return loadEquipmentUpgradeDesign(db)
}
diff --git a/go/internal/server/gamedata/event_actions.go b/go/internal/server/gamedata/event_actions.go
new file mode 100644
index 0000000..e2eca3e
--- /dev/null
+++ b/go/internal/server/gamedata/event_actions.go
@@ -0,0 +1,150 @@
+package gamedata
+
+import (
+ "bd2server/internal/server/wire"
+ "fmt"
+ "sort"
+)
+
+// EventActionRow preserves current-version design fields without introducing
+// hard-coded event IDs. Runtime modules consume the named table and fields.
+type EventActionRow struct {
+ Values map[int]uint64
+ Rewards []Reward
+ EventRewards []Reward
+ Text map[int]string
+}
+
+func (r EventActionRow) V(field int) uint64 { return r.Values[field] }
+
+type EventActionsDesign struct {
+ Tables map[string][]EventActionRow
+ SpawnRewards map[[2]uint64]Reward
+}
+
+func (d *EventActionsDesign) Row(table string, idField int, id uint64) (EventActionRow, bool) {
+ for _, r := range d.Tables[table] {
+ if r.V(idField) == id {
+ return r, true
+ }
+ }
+ return EventActionRow{}, false
+}
+func LoadEventActionsDesign(root, version string) (*EventActionsDesign, error) {
+ db, done, e := openStatDatabase(root, version)
+ if e != nil {
+ return nil, e
+ }
+ defer done()
+ d := &EventActionsDesign{Tables: map[string][]EventActionRow{}, SpawnRewards: map[[2]uint64]Reward{}}
+ for _, table := range []string{"FieldEventDefaultTable", "FieldSpawnEventTable", "FieldEventMonsterTable", "FireworksTable", "VotingEventTable", "VotingCandidateTable", "VotingRoundTable", "VotingCountRewardTable", "VotingSeasonTable", "FriendshipSpecialEpisodeTable", "NpcQuizTable", "TacticsBingoGroupTable", "TacticsBingoTable", "CafeteriaDefaultTable", "CafeteriaEventTable"} {
+ rows, e := db.Query("SELECT ProtoBuf FROM " + table)
+ if e != nil {
+ return nil, e
+ }
+ for rows.Next() {
+ var p []byte
+ if e = rows.Scan(&p); e != nil {
+ rows.Close()
+ return nil, e
+ }
+ r := EventActionRow{Values: map[int]uint64{}, Text: map[int]string{}}
+ if e = wire.Walk(p, func(f wire.Field) error {
+ if f.Type == 0 {
+ v, _, er := wire.Varint(p, f.Number)
+ if er != nil {
+ return er
+ }
+ r.Values[f.Number] = v
+ } else if f.Type == 2 {
+ r.Text[f.Number] = string(f.Value)
+ }
+ return nil
+ }); e != nil {
+ rows.Close()
+ return nil, e
+ }
+ var array [3]int
+ switch table {
+ case "FriendshipSpecialEpisodeTable":
+ array = [3]int{9, 8, 7}
+ case "NpcQuizTable":
+ array = [3]int{12, 11, 10}
+ }
+ if array[0] > 0 {
+ rs, e := monsterHuntRewardArrays(p, array[0], array[1], array[2])
+ if e != nil {
+ rows.Close()
+ return nil, e
+ }
+ for _, x := range rs {
+ r.Rewards = append(r.Rewards, Reward{x.Type, x.ID, x.Count})
+ }
+ }
+ if table == "FriendshipSpecialEpisodeTable" {
+ rs, e := monsterHuntRewardArrays(p, 4, 3, 2)
+ if e != nil {
+ rows.Close()
+ return nil, e
+ }
+ for _, x := range rs {
+ r.EventRewards = append(r.EventRewards, Reward{x.Type, x.ID, x.Count})
+ }
+ }
+ if table == "FireworksTable" {
+ r.Rewards = []Reward{{r.V(9), r.V(8), r.V(7)}}
+ }
+ if table == "VotingCountRewardTable" {
+ r.Rewards = []Reward{{r.V(5), r.V(4), r.V(3)}}
+ }
+ d.Tables[table] = append(d.Tables[table], r)
+ }
+ e = rows.Err()
+ rows.Close()
+ if e != nil {
+ return nil, e
+ }
+ }
+ packs := map[uint64]bool{}
+ for _, r := range d.Tables["FieldEventMonsterTable"] {
+ packs[r.V(5)] = true
+ }
+ for pack := range packs {
+ pdb, closePack, e := openPackDatabase(root, version, int(pack))
+ if e != nil {
+ return nil, e
+ }
+ for _, r := range d.Tables["FieldEventMonsterTable"] {
+ if r.V(5) != pack {
+ continue
+ }
+ var p []byte
+ if e = pdb.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", r.V(1)).Scan(&p); e != nil {
+ closePack()
+ return nil, fmt.Errorf("event spawn monster %d: %w", r.V(1), e)
+ }
+ t, e := optionalScalar(p, 29)
+ if e != nil {
+ closePack()
+ return nil, e
+ }
+ id, e := optionalScalar(p, 28)
+ if e != nil {
+ closePack()
+ return nil, e
+ }
+ count, e := optionalScalar(p, 27)
+ if e != nil {
+ closePack()
+ return nil, e
+ }
+ d.SpawnRewards[[2]uint64{pack, r.V(1)}] = Reward{t, id, count}
+ }
+ closePack()
+ }
+ for table, rows := range d.Tables {
+ sort.Slice(rows, func(i, j int) bool { return rows[i].V(2) < rows[j].V(2) })
+ d.Tables[table] = rows
+ }
+ return d, nil
+}
diff --git a/go/internal/server/gamedata/event_actions_test.go b/go/internal/server/gamedata/event_actions_test.go
new file mode 100644
index 0000000..dea60ea
--- /dev/null
+++ b/go/internal/server/gamedata/event_actions_test.go
@@ -0,0 +1,24 @@
+package gamedata
+
+import (
+ "os"
+ "testing"
+)
+
+func TestInstalledEventActions23510(t *testing.T) {
+ root := os.Getenv("BD2_REAL_GAMEDATA")
+ if root == "" {
+ t.Skip("BD2_REAL_GAMEDATA not configured")
+ }
+ d, e := LoadEventActionsDesign(root, "20260923193640")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if len(d.Tables["VotingEventTable"]) != 6 || len(d.Tables["FriendshipSpecialEpisodeTable"]) != 120 || len(d.Tables["NpcQuizTable"]) != 43 {
+ t.Fatal("event actions incomplete")
+ }
+ r := d.SpawnRewards[[2]uint64{3009, 3001}]
+ if r.Type != 4 || r.Count != 10000 {
+ t.Fatalf("spawn reward mismatch %+v", r)
+ }
+}
diff --git a/go/internal/server/gamedata/event_ap.go b/go/internal/server/gamedata/event_ap.go
new file mode 100644
index 0000000..4c55599
--- /dev/null
+++ b/go/internal/server/gamedata/event_ap.go
@@ -0,0 +1,26 @@
+package gamedata
+
+func LoadEventAPDesign(root, version string) (map[uint64]uint64, HuntingAPDesign, error) {
+ d, e := LoadHuntingAPDesign(root, version)
+ if e != nil {
+ return nil, d, e
+ }
+ db, done, e := openStatDatabase(root, version)
+ if e != nil {
+ return nil, d, e
+ }
+ defer done()
+ var raw []byte
+ if e = db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); e != nil {
+ return nil, d, e
+ }
+ caps := map[uint64]uint64{}
+ for typ, field := range map[uint64]int{30: 43, 32: 113} {
+ n, e := optionalScalar(raw, field)
+ if e != nil {
+ return nil, d, e
+ }
+ caps[typ] = n
+ }
+ return caps, d, nil
+}
diff --git a/go/internal/server/gamedata/event_battle_challenge.go b/go/internal/server/gamedata/event_battle_challenge.go
new file mode 100644
index 0000000..4c46b06
--- /dev/null
+++ b/go/internal/server/gamedata/event_battle_challenge.go
@@ -0,0 +1,96 @@
+package gamedata
+
+import (
+ "bd2server/internal/server/wire"
+ "encoding/binary"
+ "fmt"
+)
+
+type EventBattleChallenge struct {
+ Type, Value1, Value2 uint64
+ Reward BattleReward
+}
+type EventBattleChallenges map[uint64][]EventBattleChallenge
+
+func LoadEventBattleChallenges(root, version string) (EventBattleChallenges, error) {
+ db, done, e := openStatDatabase(root, version)
+ if e != nil {
+ return nil, e
+ }
+ defer done()
+ rows, e := db.Query("SELECT id,ProtoBuf FROM BattleDeckTable")
+ if e != nil {
+ return nil, e
+ }
+ defer rows.Close()
+ out := EventBattleChallenges{}
+ for rows.Next() {
+ var id uint64
+ var raw []byte
+ if e = rows.Scan(&id, &raw); e != nil {
+ return nil, e
+ }
+ fields := map[int][]uint64{}
+ for _, n := range []int{6, 7, 8, 9, 10, 11} {
+ v, e := packedInts(raw, n)
+ if e != nil {
+ return nil, e
+ }
+ fields[n] = v
+ }
+ n := len(fields[6])
+ if n == 0 {
+ continue
+ }
+ for _, f := range []int{7, 8, 9, 10, 11} {
+ if len(fields[f]) != n {
+ return nil, fmt.Errorf("gamedata: event challenge arrays mismatch deck%d", id)
+ }
+ }
+ for i, t := range fields[6] {
+ if t < 1 || t > 8 {
+ return nil, fmt.Errorf("gamedata: unsupported challenge type%d", t)
+ }
+ out[id] = append(out[id], EventBattleChallenge{Type: t, Value1: fields[7][i], Value2: fields[8][i], Reward: BattleReward{Type: fields[11][i], ID: fields[10][i], Count: fields[9][i]}})
+ }
+ }
+ return out, rows.Err()
+}
+
+// VerifySubmittedChallenges uses the client's simulation reports. Conditions
+// absent from BattleEnd (turn count/used skill/team elements) are accepted as
+// explicit local simulation attestations after static index/type validation.
+func VerifySubmittedChallenges(req []byte, definitions []EventBattleChallenge) ([]uint64, error) {
+ var indexes []uint64
+ seen := map[uint64]bool{}
+ e := wire.Walk(req, func(f wire.Field) error {
+ if f.Number != 5 {
+ return nil
+ }
+ var v []uint64
+ var e error
+ if f.Type == 0 {
+ n, k := binary.Uvarint(f.Value)
+ if k <= 0 {
+ return wire.ErrMalformed
+ }
+ v = []uint64{n}
+ } else if f.Type == 2 {
+ v, e = packedInts(wire.AppendBytes(nil, 5, f.Value), 5)
+ } else {
+ return wire.ErrMalformed
+ }
+ if e != nil {
+ return e
+ }
+ for _, i := range v {
+ if i >= uint64(len(definitions)) || seen[i] {
+ return fmt.Errorf("gamedata: invalid challenge index")
+ }
+ seen[i] = true
+ indexes = append(indexes, i)
+ }
+ return nil
+ })
+ return indexes, e
+}
diff --git a/go/internal/server/gamedata/event_battle_challenge_test.go b/go/internal/server/gamedata/event_battle_challenge_test.go
new file mode 100644
index 0000000..29ec770
--- /dev/null
+++ b/go/internal/server/gamedata/event_battle_challenge_test.go
@@ -0,0 +1,32 @@
+package gamedata
+
+import (
+ "bd2server/internal/server/wire"
+ "os"
+ "testing"
+)
+
+func TestEventBattleChallengeInstalledSmoke(t *testing.T) {
+ root, version := os.Getenv("BD2_GAMEDATA_ROOT"), os.Getenv("BD2_GAMEDATA_VERSION")
+ if root == "" || version == "" {
+ t.Skip("installed design env required")
+ }
+ d, e := LoadEventBattleChallenges(root, version)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if len(d) == 0 {
+ t.Fatal("empty challenges")
+ }
+}
+func TestChallengeIndexesPackedAndDuplicate(t *testing.T) {
+ definitions := []EventBattleChallenge{{Type: 8}, {Type: 2, Value1: 8}}
+ req := wire.AppendBytes(nil, 5, []byte{0, 1})
+ out, e := VerifySubmittedChallenges(req, definitions)
+ if e != nil || len(out) != 2 {
+ t.Fatal(out, e)
+ }
+ if _, e = VerifySubmittedChallenges(wire.AppendBytes(nil, 5, []byte{0, 0}), definitions); e == nil {
+ t.Fatal("duplicate index")
+ }
+}
diff --git a/go/internal/server/gamedata/event_exchange.go b/go/internal/server/gamedata/event_exchange.go
new file mode 100644
index 0000000..12cebac
--- /dev/null
+++ b/go/internal/server/gamedata/event_exchange.go
@@ -0,0 +1,101 @@
+package gamedata
+
+import "fmt"
+
+type EventExchangeEntry struct {
+ ID, Page, KeyType, Ratio, LimitedRatio, SetCount uint64
+ Reward Reward
+}
+type EventExchangeGroup struct {
+ ID, StartPage, EndPage, FreeCount, FreeType, UnlockRatio uint64
+ Repeat bool
+ Cost Reward
+ Entries []EventExchangeEntry
+}
+type EventExchangeCatalog struct{ Groups map[uint64]EventExchangeGroup }
+
+func LoadEventExchangeCatalog(root, version string) (*EventExchangeCatalog, error) {
+ db, done, err := openStatDatabase(root, version)
+ if err != nil {
+ return nil, err
+ }
+ defer done()
+ d := &EventExchangeCatalog{Groups: map[uint64]EventExchangeGroup{}}
+ rows, err := db.Query("SELECT id,ProtoBuf FROM EventCoinExchangeGroupTable ORDER BY id")
+ if err != nil {
+ return nil, err
+ }
+ for rows.Next() {
+ var id uint64
+ var raw []byte
+ if err = rows.Scan(&id, &raw); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ g := EventExchangeGroup{ID: id}
+ for n, p := range map[int]*uint64{22: &g.StartPage, 4: &g.EndPage, 7: &g.FreeCount, 8: &g.FreeType, 25: &g.UnlockRatio, 14: &g.Cost.Count, 15: &g.Cost.ID, 16: &g.Cost.Type} {
+ *p, err = optionalScalar(raw, n)
+ if err != nil {
+ rows.Close()
+ return nil, err
+ }
+ }
+ repeat, e := optionalScalar(raw, 12)
+ if e != nil {
+ rows.Close()
+ return nil, e
+ }
+ g.Repeat = repeat != 0
+ if g.StartPage == 0 || g.EndPage < g.StartPage || g.Cost.Count == 0 || g.Cost.Type == 0 || g.FreeType > 2 || g.ID > 2147483647 || g.EndPage > 2147483647 || g.Cost.Count > 2147483647 || g.FreeCount > 2147483647 {
+ rows.Close()
+ return nil, fmt.Errorf("gamedata: invalid exchange group %d", id)
+ }
+ d.Groups[id] = g
+ }
+ if err = rows.Err(); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ rows.Close()
+ rows, err = db.Query("SELECT groupId,id,pageId,ProtoBuf FROM EventCoinExchangeTable ORDER BY groupId,id")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var group, id, page uint64
+ var raw []byte
+ if err = rows.Scan(&group, &id, &page, &raw); err != nil {
+ return nil, err
+ }
+ g, ok := d.Groups[group]
+ if !ok {
+ return nil, fmt.Errorf("gamedata: exchange group missing")
+ }
+ e := EventExchangeEntry{ID: id, Page: page}
+ for n, p := range map[int]*uint64{3: &e.KeyType, 4: &e.LimitedRatio, 6: &e.Ratio, 7: &e.Reward.Count, 8: &e.Reward.ID, 9: &e.Reward.Type, 10: &e.SetCount} {
+ *p, err = optionalScalar(raw, n)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if e.ID == 0 || e.Page < g.StartPage || e.Page > g.EndPage || e.SetCount == 0 || e.Ratio == 0 || e.Reward.Type == 0 || e.Reward.Count == 0 || e.SetCount > 2147483647 || e.Reward.Count > 2147483647 || e.ID > 2147483647 {
+ return nil, fmt.Errorf("gamedata: invalid exchange entry %d:%d", group, id)
+ }
+ g.Entries = append(g.Entries, e)
+ d.Groups[group] = g
+ }
+ return d, rows.Err()
+}
+func (g EventExchangeGroup) Page(page uint64) []EventExchangeEntry {
+ if page > g.EndPage {
+ page = g.EndPage
+ }
+ var out []EventExchangeEntry
+ for _, e := range g.Entries {
+ if e.Page == page {
+ out = append(out, e)
+ }
+ }
+ return out
+}
diff --git a/go/internal/server/gamedata/event_games.go b/go/internal/server/gamedata/event_games.go
new file mode 100644
index 0000000..80a0ec5
--- /dev/null
+++ b/go/internal/server/gamedata/event_games.go
@@ -0,0 +1,213 @@
+package gamedata
+
+import "fmt"
+
+type EventGameReward struct {
+ ID, Count, LineType, LineIndex, Slot, Weight, Weight2 uint64
+ Members []uint64
+ Rewards []BattleReward
+}
+type EventGame struct {
+ ID, Type, Columns, CostType, CostID, Cost, ScaffoldGroup, Controller, Free, Pity, MaxConsume uint64
+ Cells, Lines, Complete []EventGameReward
+ Moves []struct{ ID, Min, Max uint64 }
+ RewardGroup uint64
+}
+
+func LoadEventGame(root, version string, kind, id uint64) (*EventGame, error) {
+ db, done, e := openStatDatabase(root, version)
+ if e != nil {
+ return nil, e
+ }
+ defer done()
+ tables := map[uint64]string{12: "MiniGameBoardTable", 13: "MiniGameBingoTable", 17: "MiniGamePuzzleEventTable", 19: "MiniGameRouletteTable"}
+ table := tables[kind]
+ if table == "" {
+ return nil, fmt.Errorf("gamedata: unsupported event game")
+ }
+ var raw []byte
+ if e = db.QueryRow("SELECT ProtoBuf FROM "+table+" WHERE id=?", id).Scan(&raw); e != nil {
+ return nil, e
+ }
+ d := &EventGame{ID: id, Type: kind}
+ var cellGroup, lineGroup, completeGroup uint64
+ fields := map[int]*uint64{}
+ switch kind {
+ case 12:
+ fields = map[int]*uint64{4: &d.Cost, 5: &d.CostID, 6: &d.CostType, 7: &completeGroup, 9: &d.Controller, 11: &d.ScaffoldGroup}
+ case 13:
+ fields = map[int]*uint64{1: &completeGroup, 2: &lineGroup, 3: &cellGroup, 5: &d.Columns, 7: &d.Cost, 8: &d.CostID, 9: &d.CostType}
+ case 17:
+ fields = map[int]*uint64{1: &d.Columns, 3: &d.Cost, 4: &d.CostID, 5: &d.CostType, 6: &completeGroup, 7: &cellGroup}
+ case 19:
+ fields = map[int]*uint64{1: &d.Free, 3: &d.Cost, 4: &d.CostID, 5: &d.MaxConsume, 6: &d.CostType, 7: &d.Pity, 8: &completeGroup, 9: &cellGroup}
+ }
+ for n, p := range fields {
+ *p, e = optionalScalar(raw, n)
+ if e != nil {
+ return nil, e
+ }
+ }
+ d.RewardGroup = cellGroup
+ load := func(table string, group uint64, mode string) ([]EventGameReward, error) {
+ rows, e := db.Query("SELECT ProtoBuf FROM "+table+" WHERE groupId=? ORDER BY id", group)
+ if e != nil {
+ return nil, e
+ }
+ defer rows.Close()
+ var out []EventGameReward
+ for rows.Next() {
+ var b []byte
+ if e = rows.Scan(&b); e != nil {
+ return nil, e
+ }
+ r := EventGameReward{}
+ idField, countField, typ, ids, counts := 3, 1, 6, 5, 4
+ switch mode {
+ case "scaffold":
+ idField = 2
+ countField = 0
+ typ = 5
+ ids = 4
+ counts = 3
+ case "line":
+ typ = 8
+ ids = 7
+ counts = 6
+ r.LineType, e = optionalScalar(b, 5)
+ if e != nil {
+ return nil, e
+ }
+ r.LineIndex, e = optionalScalar(b, 4)
+ case "roulette":
+ idField = 2
+ countField = 0
+ typ = 7
+ ids = 6
+ counts = 5
+ r.Slot, e = optionalScalar(b, 8)
+ if e != nil {
+ return nil, e
+ }
+ r.Weight, e = optionalScalar(b, 3)
+ if e != nil {
+ return nil, e
+ }
+ r.Weight2, e = optionalScalar(b, 4)
+ case "puzzlecomplete":
+ typ = 7
+ ids = 6
+ counts = 5
+ r.Members, e = packedInts(b, 4)
+ case "puzzle":
+ r.Slot, e = optionalScalar(b, 7)
+ }
+ if e != nil {
+ return nil, e
+ }
+ r.ID, e = optionalScalar(b, idField)
+ if e != nil {
+ return nil, e
+ }
+ if countField > 0 {
+ r.Count, e = optionalScalar(b, countField)
+ if e != nil {
+ return nil, e
+ }
+ }
+ r.Rewards, e = eventGameRewards(b, typ, ids, counts)
+ if e != nil {
+ return nil, e
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+ }
+ switch kind {
+ case 12:
+ d.Cells, e = load("MiniGameScaffoldTable", d.ScaffoldGroup, "scaffold")
+ if e == nil {
+ d.Complete, e = load("MiniGameCompleteRewardTable", completeGroup, "default")
+ }
+ if e != nil {
+ return nil, e
+ }
+ rows, e := db.Query("SELECT ProtoBuf FROM MiniGameMoveControllerTable WHERE groupId=? ORDER BY id", d.Controller)
+ if e != nil {
+ return nil, e
+ }
+ for rows.Next() {
+ var b []byte
+ if e = rows.Scan(&b); e != nil {
+ rows.Close()
+ return nil, e
+ }
+ i, e := optionalScalar(b, 3)
+ if e != nil {
+ rows.Close()
+ return nil, e
+ }
+ min, _ := optionalScalar(b, 5)
+ max, _ := optionalScalar(b, 4)
+ d.Moves = append(d.Moves, struct{ ID, Min, Max uint64 }{i, min, max})
+ }
+ e = rows.Err()
+ rows.Close()
+ case 13:
+ d.Cells, e = load("BingoRewardGroupTable", cellGroup, "default")
+ if e == nil {
+ d.Lines, e = load("BingoLineRewardGroupTable", lineGroup, "line")
+ }
+ if e == nil {
+ d.Complete, e = load("BingoCompleteRewardGroupTable", completeGroup, "default")
+ }
+ case 17:
+ d.Cells, e = load("PuzzleRewardGroupTable", cellGroup, "puzzle")
+ if e == nil {
+ d.Complete, e = load("PuzzleCompleteRewardGroupTable", completeGroup, "puzzlecomplete")
+ }
+ case 19:
+ d.Cells, e = load("RouletteRewardGroupTable", cellGroup, "roulette")
+ if e == nil {
+ d.Complete, e = load("RouletteAccumulatedRewardTable", completeGroup, "default")
+ }
+ }
+ if e != nil {
+ return nil, e
+ }
+ if d.Cost == 0 || len(d.Cells) == 0 {
+ return nil, fmt.Errorf("gamedata: empty event game rules")
+ }
+ return d, nil
+}
+
+func eventGameRewards(raw []byte, typ, ids, counts int) ([]BattleReward, error) {
+ ts, e := packedInts(raw, typ)
+ if e != nil {
+ return nil, e
+ }
+ is, e := packedInts(raw, ids)
+ if e != nil {
+ return nil, e
+ }
+ cs, e := packedInts(raw, counts)
+ if e != nil {
+ return nil, e
+ }
+ if len(ts) == 0 && len(cs) == 0 {
+ return nil, nil
+ }
+ if len(is) == 0 {
+ is = make([]uint64, len(ts))
+ }
+ if len(ts) != len(is) || len(ts) != len(cs) {
+ return nil, fmt.Errorf("gamedata: event game reward arrays differ")
+ }
+ var out []BattleReward
+ for i, t := range ts {
+ if t != 0 && cs[i] != 0 {
+ out = append(out, BattleReward{Type: t, ID: is[i], Count: cs[i]})
+ }
+ }
+ return out, nil
+}
diff --git a/go/internal/server/gamedata/event_games_test.go b/go/internal/server/gamedata/event_games_test.go
new file mode 100644
index 0000000..4adaff6
--- /dev/null
+++ b/go/internal/server/gamedata/event_games_test.go
@@ -0,0 +1,31 @@
+package gamedata
+
+import (
+ "os"
+ "testing"
+)
+
+func TestEventGameInstalledRules(t *testing.T) {
+ root := os.Getenv("BD2_REAL_GAMEDATA")
+ if root == "" {
+ t.Skip("BD2_REAL_GAMEDATA not configured")
+ }
+ for _, kind := range []uint64{12, 13, 17, 19} {
+ d, e := LoadEventGame(root, "20260923193640", kind, 1)
+ if e != nil {
+ t.Fatalf("kind=%d %v", kind, e)
+ }
+ if d.Type != kind || d.Cost == 0 || len(d.Cells) == 0 {
+ t.Fatalf("kind=%d invalid %+v", kind, d)
+ }
+ if kind == 12 && len(d.Moves) == 0 {
+ t.Fatal("no moves")
+ }
+ if kind == 13 && len(d.Lines) == 0 {
+ t.Fatal("no lines")
+ }
+ if kind == 17 && len(d.Complete) == 0 {
+ t.Fatal("no words")
+ }
+ }
+}
diff --git a/go/internal/server/gamedata/event_owned_item.go b/go/internal/server/gamedata/event_owned_item.go
new file mode 100644
index 0000000..cccc177
--- /dev/null
+++ b/go/internal/server/gamedata/event_owned_item.go
@@ -0,0 +1,31 @@
+package gamedata
+
+func LoadOwnedEventItemDesign(root, version string) (map[uint64]map[uint64]bool, error) {
+ db, done, e := openStatDatabase(root, version)
+ if e != nil {
+ return nil, e
+ }
+ defer done()
+ out := map[uint64]map[uint64]bool{}
+ for typ, table := range map[uint64]string{47: "IdCardItemTable", 49: "AvatarItemTable"} {
+ rows, e := db.Query("SELECT id FROM " + table)
+ if e != nil {
+ return nil, e
+ }
+ out[typ] = map[uint64]bool{}
+ for rows.Next() {
+ var id uint64
+ if e = rows.Scan(&id); e != nil {
+ rows.Close()
+ return nil, e
+ }
+ out[typ][id] = true
+ }
+ if e = rows.Err(); e != nil {
+ rows.Close()
+ return nil, e
+ }
+ rows.Close()
+ }
+ return out, nil
+}
diff --git a/go/internal/server/gamedata/event_play.go b/go/internal/server/gamedata/event_play.go
new file mode 100644
index 0000000..5f75b0c
--- /dev/null
+++ b/go/internal/server/gamedata/event_play.go
@@ -0,0 +1,101 @@
+package gamedata
+
+import (
+ "fmt"
+ "sort"
+)
+
+// EventPlayCatalog keeps the installed static rows for event gameplay.
+type EventPlayCatalog struct {
+ Tables map[string][][]byte
+ root, version string
+}
+
+func LoadEventPlayCatalog(root, version string) (*EventPlayCatalog, error) {
+ db, done, e := openStatDatabase(root, version)
+ if e != nil {
+ return nil, e
+ }
+ defer done()
+ c := &EventPlayCatalog{Tables: map[string][][]byte{}, root: root, version: version}
+ names := []string{"PackEventHubTable", "PackEventListTable", "PackEventStoryGroupTable", "PackEventStoryTable", "PackEventBattleGroupTable", "PackEventBattleTable", "PackEventMiniGameTable", "FieldMiniGameRewardTable", "FieldMiniGameSpeedTable", "RhythmGameMusicTable", "RhythmGameGradeTable", "SichuanStageTable", "SichuanEventTable", "SichuanRewardTable", "HopscotchStageTable", "HopscotchRewardTable", "HopscotchDefaultTable", "FieldMiniGameSurvivalTable", "ActionGameDefaultTable", "ActionGameMissionTable", "ActionGameStageTable", "FieldMiniGameCharTable", "FieldMiniGameMapTable", "FieldMiniGameUpgradeTable", "FieldMiniGameUpgradeGroupTable", "FieldMiniGameSkillGroupTable", "FieldMiniGameSkillTable", "FieldMiniGameCharLevelTable", "FieldMiniGameMonsterTable", "FieldMiniGameSurvivalItemTable", "FieldMiniGameSurvivalBoxTable", "MGDRewardTable", "MGDWaveTable", "MGDDefaultTable"}
+ for _, name := range names {
+ rows, e := db.Query("SELECT ProtoBuf FROM " + name)
+ if e != nil {
+ return nil, e
+ }
+ for rows.Next() {
+ var raw []byte
+ if e = rows.Scan(&raw); e != nil {
+ rows.Close()
+ return nil, e
+ }
+ c.Tables[name] = append(c.Tables[name], append([]byte(nil), raw...))
+ }
+ e = rows.Err()
+ rows.Close()
+ if e != nil {
+ return nil, e
+ }
+ }
+ return c, nil
+}
+func (c *EventPlayCatalog) Row(table string, n int, id uint64) ([]byte, error) {
+ for _, r := range c.Tables[table] {
+ v, e := optionalScalar(r, n)
+ if e != nil {
+ return nil, e
+ }
+ if v == id {
+ return r, nil
+ }
+ }
+ return nil, fmt.Errorf("gamedata: %s row %d missing", table, id)
+}
+func (c *EventPlayCatalog) Rows(table string, n int, id uint64) [][]byte {
+ var out [][]byte
+ for _, r := range c.Tables[table] {
+ v, _ := optionalScalar(r, n)
+ if v == id {
+ out = append(out, r)
+ }
+ }
+ return out
+}
+func EventPlayRewards(raw []byte, typ, id, count int) ([]BattleReward, error) {
+ return eventGameRewards(raw, typ, id, count)
+}
+func (c *EventPlayCatalog) Story(group, id uint64) ([]byte, error) {
+ for _, r := range c.Rows("PackEventStoryTable", 1, group) {
+ v, _ := optionalScalar(r, 2)
+ if v == id {
+ return r, nil
+ }
+ }
+ return nil, fmt.Errorf("gamedata: missing event story")
+}
+func (c *EventPlayCatalog) ScoreRewards(group, score uint64) (uint64, []BattleReward, error) {
+ var picked []byte
+ point := uint64(0)
+ rows := c.Rows("FieldMiniGameRewardTable", 1, group)
+ sort.Slice(rows, func(i, j int) bool {
+ a, _ := optionalScalar(rows[i], 3)
+ b, _ := optionalScalar(rows[j], 3)
+ return a < b
+ })
+ for _, r := range rows {
+ p, e := optionalScalar(r, 3)
+ if e != nil {
+ return 0, nil, e
+ }
+ if p <= score && p >= point {
+ picked = r
+ point = p
+ }
+ }
+ if picked == nil {
+ return 0, nil, nil
+ }
+ rewards, e := eventGameRewards(picked, 6, 5, 4)
+ return point, rewards, e
+}
diff --git a/go/internal/server/gamedata/event_play_field.go b/go/internal/server/gamedata/event_play_field.go
new file mode 100644
index 0000000..771c0b3
--- /dev/null
+++ b/go/internal/server/gamedata/event_play_field.go
@@ -0,0 +1,48 @@
+package gamedata
+
+import "fmt"
+
+type EventField struct {
+ HP, Limit uint64
+ Objects map[uint64]struct{ Point, Type uint64 }
+}
+
+func (c *EventPlayCatalog) Field(game uint64) (*EventField, error) {
+ g, e := c.Row("PackEventMiniGameTable", 8, game)
+ if e != nil {
+ return nil, e
+ }
+ pack, _ := optionalScalar(g, 12)
+ target, _ := optionalScalar(g, 4)
+ db, done, e := openPackDatabase(c.root, c.version, int(pack))
+ if e != nil {
+ return nil, e
+ }
+ defer done()
+ var raw []byte
+ if e = db.QueryRow("SELECT ProtoBuf FROM FieldMiniGameTable WHERE id=?", target).Scan(&raw); e != nil {
+ return nil, e
+ }
+ group, _ := optionalScalar(raw, 3)
+ d := &EventField{Objects: map[uint64]struct{ Point, Type uint64 }{}}
+ d.HP, _ = optionalScalar(raw, 6)
+ d.Limit, _ = optionalScalar(raw, 7)
+ rows, e := db.Query("SELECT ProtoBuf FROM FieldMiniGameObjectTable WHERE groupId=? ORDER BY id", group)
+ if e != nil {
+ return nil, e
+ }
+ defer rows.Close()
+ for rows.Next() {
+ if e = rows.Scan(&raw); e != nil {
+ return nil, e
+ }
+ id, _ := optionalScalar(raw, 2)
+ point, _ := optionalScalar(raw, 3)
+ typ, _ := optionalScalar(raw, 4)
+ d.Objects[id] = struct{ Point, Type uint64 }{point, typ}
+ }
+ if len(d.Objects) == 0 {
+ return nil, fmt.Errorf("gamedata: field game objects missing")
+ }
+ return d, rows.Err()
+}
diff --git a/go/internal/server/gamedata/event_play_test.go b/go/internal/server/gamedata/event_play_test.go
new file mode 100644
index 0000000..a33bb08
--- /dev/null
+++ b/go/internal/server/gamedata/event_play_test.go
@@ -0,0 +1,22 @@
+package gamedata
+
+import (
+ "os"
+ "testing"
+)
+
+func TestEventPlayInstalledCatalog(t *testing.T) {
+ root := os.Getenv("BD2_REAL_GAMEDATA")
+ if root == "" {
+ t.Skip("BD2_REAL_GAMEDATA not set")
+ }
+ c, e := LoadEventPlayCatalog(root, "20260923193640")
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, n := range []string{"PackEventStoryTable", "FieldMiniGameSkillTable", "MGDRewardTable"} {
+ if len(c.Tables[n]) == 0 {
+ t.Fatalf("empty %s", n)
+ }
+ }
+}
diff --git a/go/internal/server/gamedata/event_tasks.go b/go/internal/server/gamedata/event_tasks.go
new file mode 100644
index 0000000..e7ff25b
--- /dev/null
+++ b/go/internal/server/gamedata/event_tasks.go
@@ -0,0 +1,233 @@
+package gamedata
+
+import (
+ "database/sql"
+ "fmt"
+ "sort"
+)
+
+type EventAttendance struct{ ID, Group, Ticket uint64 }
+type EventAttendanceGroup struct{ Group, ID, Next uint64 }
+type EventAttendanceReward struct {
+ Group, ID, Day uint64
+ Basic, Premium Reward
+}
+type EventMissionGroup struct {
+ ID, Type, ScheduleType, UsePass, CompleteHide uint64
+ Groups []uint64
+}
+type EventTask struct {
+ ID, Group, Type, SubType, Target, PassExp, UnlockPack, UnlockQuest uint64
+ Params []uint64
+ Rewards []Reward
+}
+type EventPass struct {
+ ID, MissionGroup, LevelGroup, Type, ScheduleType, NewbieGroup, NewbieStep uint64
+ Core Reward
+}
+type EventPassLevel struct {
+ ID, NeedExp, PremiumType uint64
+ Basic, Premium Reward
+}
+type EventPassBuy struct {
+ ID, Type, UnlockLevel, CashGroup, CashID, CashSales uint64
+ Cost Reward
+ Rewards []Reward
+}
+type EventTasksDesign struct {
+ Attendance map[uint64]EventAttendance
+ AttendanceGroups map[[2]uint64]EventAttendanceGroup
+ AttendanceRewards map[uint64][]EventAttendanceReward
+ LimitRewards map[[2]uint64]uint64
+ MissionGroups map[uint64]EventMissionGroup
+ Missions map[uint64]EventTask
+ Passes map[uint64]EventPass
+ PassLevels map[uint64][]EventPassLevel
+ PassBuys map[uint64][]EventPassBuy
+}
+
+func LoadEventTasksDesign(root, version string) (*EventTasksDesign, error) {
+ db, done, e := openStatDatabase(root, version)
+ if e != nil {
+ return nil, e
+ }
+ defer done()
+ return loadEventTasksDesign(db)
+}
+func loadEventTasksDesign(db *sql.DB) (*EventTasksDesign, error) {
+ d := &EventTasksDesign{Attendance: map[uint64]EventAttendance{}, AttendanceGroups: map[[2]uint64]EventAttendanceGroup{}, AttendanceRewards: map[uint64][]EventAttendanceReward{}, LimitRewards: map[[2]uint64]uint64{}, MissionGroups: map[uint64]EventMissionGroup{}, Missions: map[uint64]EventTask{}, Passes: map[uint64]EventPass{}, PassLevels: map[uint64][]EventPassLevel{}, PassBuys: map[uint64][]EventPassBuy{}}
+ each := func(table string, fn func([]byte) error) error {
+ rows, e := db.Query("SELECT ProtoBuf FROM " + table)
+ if e != nil {
+ return e
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var p []byte
+ if e = rows.Scan(&p); e != nil {
+ return e
+ }
+ if e = fn(p); e != nil {
+ return fmt.Errorf("gamedata: %s: %w", table, e)
+ }
+ }
+ return rows.Err()
+ }
+ scalar := func(p []byte, n int) (uint64, error) { return optionalScalar(p, n) }
+ read := func(p []byte, m map[int]*uint64) error {
+ for n, target := range m {
+ v, e := scalar(p, n)
+ if e != nil {
+ return e
+ }
+ *target = v
+ }
+ return nil
+ }
+ reward := func(p []byte, t, i, c int) (Reward, error) {
+ r := Reward{}
+ e := read(p, map[int]*uint64{t: &r.Type, i: &r.ID, c: &r.Count})
+ return r, e
+ }
+ if e := each("AttendanceAlwaysTable", func(p []byte) error {
+ r := EventAttendance{}
+ e := read(p, map[int]*uint64{3: &r.ID, 1: &r.Group, 2: &r.Ticket})
+ d.Attendance[r.ID] = r
+ return e
+ }); e != nil {
+ return nil, e
+ }
+ if e := each("AttendanceAlwaysGroupTable", func(p []byte) error {
+ r := EventAttendanceGroup{}
+ e := read(p, map[int]*uint64{1: &r.Group, 2: &r.ID, 3: &r.Next})
+ d.AttendanceGroups[[2]uint64{r.Group, r.ID}] = r
+ return e
+ }); e != nil {
+ return nil, e
+ }
+ if e := each("AttendanceAlwaysRewardTable", func(p []byte) error {
+ r := EventAttendanceReward{}
+ if e := read(p, map[int]*uint64{2: &r.Group, 3: &r.ID, 1: &r.Day}); e != nil {
+ return e
+ }
+ var e error
+ r.Basic, e = reward(p, 9, 8, 7)
+ if e != nil {
+ return e
+ }
+ r.Premium, e = reward(p, 6, 5, 4)
+ d.AttendanceRewards[r.Group] = append(d.AttendanceRewards[r.Group], r)
+ return e
+ }); e != nil {
+ return nil, e
+ }
+ if e := each("AttendanceLimitRewardTable", func(p []byte) error {
+ var g, i, b uint64
+ e := read(p, map[int]*uint64{1: &g, 2: &i, 3: &b})
+ d.LimitRewards[[2]uint64{g, i}] = b
+ return e
+ }); e != nil {
+ return nil, e
+ }
+ if e := each("EventMissionGroupTable", func(p []byte) error {
+ r := EventMissionGroup{}
+ if e := read(p, map[int]*uint64{5: &r.ID, 1: &r.Type, 8: &r.ScheduleType, 10: &r.UsePass, 6: &r.CompleteHide}); e != nil {
+ return e
+ }
+ var e error
+ r.Groups, e = packedInts(p, 7)
+ d.MissionGroups[r.ID] = r
+ return e
+ }); e != nil {
+ return nil, e
+ }
+ if e := each("MissionTable", func(p []byte) error {
+ g, e := scalar(p, 7)
+ if e != nil {
+ return e
+ }
+ if g != 2 {
+ return nil
+ }
+ r := EventTask{}
+ if e = read(p, map[int]*uint64{8: &r.ID, 6: &r.Group, 3: &r.Type, 1: &r.SubType, 4: &r.Target, 12: &r.PassExp, 19: &r.UnlockPack, 20: &r.UnlockQuest}); e != nil {
+ return e
+ }
+ r.Params, e = packedInts(p, 2)
+ if e != nil {
+ return e
+ }
+ v, e := reward(p, 15, 14, 13)
+ if e != nil {
+ return e
+ }
+ if v.Type > 0 && v.Count > 0 {
+ r.Rewards = []Reward{v}
+ }
+ d.Missions[r.ID] = r
+ return nil
+ }); e != nil {
+ return nil, e
+ }
+ if e := each("PassTable", func(p []byte) error {
+ r := EventPass{}
+ if e := read(p, map[int]*uint64{6: &r.ID, 5: &r.MissionGroup, 11: &r.LevelGroup, 13: &r.Type, 15: &r.ScheduleType, 9: &r.NewbieGroup, 10: &r.NewbieStep}); e != nil {
+ return e
+ }
+ var e error
+ r.Core, e = reward(p, 4, 3, 2)
+ d.Passes[r.ID] = r
+ return e
+ }); e != nil {
+ return nil, e
+ }
+ if e := each("PassLevelTable", func(p []byte) error {
+ r := EventPassLevel{}
+ var g uint64
+ if e := read(p, map[int]*uint64{4: &g, 5: &r.ID, 6: &r.NeedExp, 10: &r.PremiumType}); e != nil {
+ return e
+ }
+ var e error
+ r.Basic, e = reward(p, 3, 2, 1)
+ if e != nil {
+ return e
+ }
+ r.Premium, e = reward(p, 9, 8, 7)
+ d.PassLevels[g] = append(d.PassLevels[g], r)
+ return e
+ }); e != nil {
+ return nil, e
+ }
+ if e := each("PassBuyTable", func(p []byte) error {
+ r := EventPassBuy{}
+ var g uint64
+ if e := read(p, map[int]*uint64{7: &g, 8: &r.ID, 12: &r.Type, 13: &r.UnlockLevel, 4: &r.CashGroup, 5: &r.CashID, 6: &r.CashSales}); e != nil {
+ return e
+ }
+ var e error
+ r.Cost, e = reward(p, 3, 2, 1)
+ if e != nil {
+ return e
+ }
+ rs, e := monsterHuntRewardArrays(p, 11, 10, 9)
+ if e != nil {
+ return e
+ }
+ for _, v := range rs {
+ r.Rewards = append(r.Rewards, Reward{v.Type, v.ID, v.Count})
+ }
+ d.PassBuys[g] = append(d.PassBuys[g], r)
+ return nil
+ }); e != nil {
+ return nil, e
+ }
+ for g, rs := range d.AttendanceRewards {
+ sort.Slice(rs, func(i, j int) bool { return rs[i].ID < rs[j].ID })
+ d.AttendanceRewards[g] = rs
+ }
+ for g, rs := range d.PassLevels {
+ sort.Slice(rs, func(i, j int) bool { return rs[i].ID < rs[j].ID })
+ d.PassLevels[g] = rs
+ }
+ return d, nil
+}
diff --git a/go/internal/server/gamedata/event_tasks_test.go b/go/internal/server/gamedata/event_tasks_test.go
new file mode 100644
index 0000000..8a2da86
--- /dev/null
+++ b/go/internal/server/gamedata/event_tasks_test.go
@@ -0,0 +1,27 @@
+package gamedata
+
+import (
+ "os"
+ "testing"
+)
+
+func TestInstalledEventTasks23510(t *testing.T) {
+ root := os.Getenv("BD2_REAL_GAMEDATA")
+ if root == "" {
+ t.Skip("BD2_REAL_GAMEDATA not configured")
+ }
+ d, e := LoadEventTasksDesign(root, "20260923193640")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if len(d.Attendance) != 109 || len(d.MissionGroups) != 519 || len(d.Passes) != 141 {
+ t.Fatal("event design tables not fully loaded")
+ }
+ p := d.Passes[141]
+ if p.MissionGroup != 51 || p.LevelGroup != 141 || d.PassLevels[141][0].NeedExp != 20 || d.PassLevels[141][0].Basic.Type != 9 {
+ t.Fatalf("pass 141 mismatch %+v", p)
+ }
+ if d.MissionGroups[51].Type != 3 || len(d.MissionGroups[51].Groups) != 7 {
+ t.Fatal("daily-open groups lost")
+ }
+}
diff --git a/go/internal/server/gamedata/field_objects.go b/go/internal/server/gamedata/field_objects.go
index 9166bf7..536cc59 100644
--- a/go/internal/server/gamedata/field_objects.go
+++ b/go/internal/server/gamedata/field_objects.go
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
- "os"
- "path/filepath"
)
// FieldObjectDesign retains the client table identities, including unsupported
@@ -21,24 +19,11 @@ type FieldRewardObject struct {
}
func LoadFieldObjects(root, version string, pack int) (FieldObjectDesign, error) {
- plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", pack))
+ db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", pack))
if err != nil {
return FieldObjectDesign{}, err
}
- dir, err := os.MkdirTemp("", "bd2-field-objects-")
- if err != nil {
- return FieldObjectDesign{}, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "pack.db")
- if err = os.WriteFile(path, plain, 0600); err != nil {
- return FieldObjectDesign{}, err
- }
- db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
- if err != nil {
- return FieldObjectDesign{}, err
- }
- defer db.Close()
+ defer release()
common, closeDB, err := openStatDatabase(root, version)
if err != nil {
return FieldObjectDesign{}, err
diff --git a/go/internal/server/gamedata/field_research.go b/go/internal/server/gamedata/field_research.go
new file mode 100644
index 0000000..953d152
--- /dev/null
+++ b/go/internal/server/gamedata/field_research.go
@@ -0,0 +1,225 @@
+package gamedata
+
+import (
+ "bd2server/internal/server/wire"
+ "database/sql"
+ "fmt"
+)
+
+type FieldResearchObject struct {
+ ID, Type, CollectionID, HoldingCollectionID, TalentObjectID int
+ Maps []int
+ QuestRange []uint64
+ InteractionQuests []int
+ Reward Reward
+}
+type FieldResearchDesign struct{ Objects map[int]FieldResearchObject }
+
+func openLogicalReadDB(root, version, name string) (*sql.DB, func(), error) {
+ return OpenDatabase(root, version, name)
+}
+func LoadFieldResearch(root, version string, pack int) (FieldResearchDesign, error) {
+ d := FieldResearchDesign{Objects: map[int]FieldResearchObject{}}
+ db, done, err := openLogicalReadDB(root, version, fmt.Sprintf("pack%d", pack))
+ if err != nil {
+ return d, err
+ }
+ defer done()
+ rows, err := db.Query("SELECT ProtoBuf FROM FieldResearchObjectTable")
+ if err != nil {
+ return d, err
+ }
+ for rows.Next() {
+ var raw []byte
+ if err = rows.Scan(&raw); err != nil {
+ rows.Close()
+ return d, err
+ }
+ var o FieldResearchObject
+ values := []*int{&o.CollectionID, &o.HoldingCollectionID, &o.ID, &o.TalentObjectID, &o.Type}
+ for i, f := range []int{1, 3, 5, 17, 18} {
+ v, e := optionalScalar(raw, f)
+ if e != nil {
+ rows.Close()
+ return d, e
+ }
+ *values[i] = int(v)
+ }
+ o.QuestRange, err = packedInts(raw, 10)
+ if err != nil {
+ rows.Close()
+ return d, err
+ }
+ o.Reward.Type, err = optionalScalar(raw, 16)
+ if err != nil {
+ rows.Close()
+ return d, err
+ }
+ o.Reward.ID, err = optionalScalar(raw, 15)
+ if err != nil {
+ rows.Close()
+ return d, err
+ }
+ o.Reward.Count, err = optionalScalar(raw, 14)
+ if err != nil {
+ rows.Close()
+ return d, err
+ }
+ if o.ID <= 0 {
+ rows.Close()
+ return d, fmt.Errorf("gamedata: invalid research id")
+ }
+ d.Objects[o.ID] = o
+ }
+ err = rows.Err()
+ rows.Close()
+ if err != nil {
+ return d, err
+ }
+ common, closeCommon, err := openStatDatabase(root, version)
+ if err != nil {
+ return d, err
+ }
+ defer closeCommon()
+ maps := map[string]int{}
+ rows, err = common.Query("SELECT id,mapScenePath FROM MapTable WHERE packId=?", pack)
+ if err != nil {
+ return d, err
+ }
+ for rows.Next() {
+ var id int
+ var scene string
+ if err = rows.Scan(&id, &scene); err != nil {
+ rows.Close()
+ return d, err
+ }
+ maps[scene] = id
+ }
+ err = rows.Err()
+ rows.Close()
+ if err != nil {
+ return d, err
+ }
+ scenes, closeScenes, err := openLogicalReadDB(root, version, "FieldObjectSceneData")
+ if err != nil {
+ return d, err
+ }
+ defer closeScenes()
+ rows, err = scenes.Query(fmt.Sprintf("SELECT ProtoBuf FROM FieldObjectSceneData%d WHERE objectType=5", pack))
+ if err != nil {
+ return d, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var raw []byte
+ if err = rows.Scan(&raw); err != nil {
+ return d, err
+ }
+ id, e := optionalScalar(raw, 8)
+ if e != nil {
+ return d, e
+ }
+ var scene string
+ if e = wire.Walk(raw, func(f wire.Field) error {
+ if f.Number == 7 && f.Type == 2 {
+ scene = string(f.Value)
+ }
+ return nil
+ }); e != nil {
+ return d, e
+ }
+ o, ok := d.Objects[int(id)]
+ if !ok {
+ return d, fmt.Errorf("gamedata: scene references missing research %d", id)
+ }
+ mapID, ok := maps[scene]
+ if !ok {
+ return d, fmt.Errorf("gamedata: research scene %s missing map", scene)
+ }
+ o.Maps = append(o.Maps, mapID)
+ d.Objects[o.ID] = o
+ }
+ if err = rows.Err(); err != nil {
+ return d, err
+ }
+ rows.Close()
+ rows, err = common.Query(fmt.Sprintf("SELECT id,ProtoBuf FROM QuestTable%d", pack))
+ if err != nil {
+ return d, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var id int
+ var raw []byte
+ if err = rows.Scan(&id, &raw); err != nil {
+ return d, err
+ }
+ condition, e := optionalScalar(raw, 11)
+ if e != nil {
+ return d, e
+ }
+ if condition != 18 {
+ continue
+ }
+ objects, e := packedInts(raw, 33)
+ if e != nil {
+ return d, e
+ }
+ for _, object := range objects {
+ if o, ok := d.Objects[int(object)]; ok {
+ o.InteractionQuests = append(o.InteractionQuests, id)
+ d.Objects[o.ID] = o
+ }
+ }
+ }
+ return d, rows.Err()
+}
+
+// Research characters are identified by the client's FieldRewardResearch class
+// (6), joined through CharTable's talent ID rather than a fixed character list.
+func LoadResearchCharacters(root, version string) (map[uint64]bool, error) {
+ db, done, err := openStatDatabase(root, version)
+ if err != nil {
+ return nil, err
+ }
+ defer done()
+ talents := map[uint64]bool{}
+ rows, err := db.Query("SELECT id FROM TalentTable WHERE classType=6")
+ if err != nil {
+ return nil, err
+ }
+ for rows.Next() {
+ var id uint64
+ if err = rows.Scan(&id); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ talents[id] = true
+ }
+ err = rows.Err()
+ rows.Close()
+ if err != nil {
+ return nil, err
+ }
+ chars := map[uint64]bool{}
+ rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var id uint64
+ var raw []byte
+ if err = rows.Scan(&id, &raw); err != nil {
+ return nil, err
+ }
+ talent, e := optionalScalar(raw, 18)
+ if e != nil {
+ return nil, e
+ }
+ if talents[talent] {
+ chars[id] = true
+ }
+ }
+ return chars, rows.Err()
+}
diff --git a/go/internal/server/gamedata/field_research_test.go b/go/internal/server/gamedata/field_research_test.go
new file mode 100644
index 0000000..f3af459
--- /dev/null
+++ b/go/internal/server/gamedata/field_research_test.go
@@ -0,0 +1,28 @@
+package gamedata
+
+import (
+ "os"
+ "testing"
+)
+
+func TestInstalledFieldResearch23510(t *testing.T) {
+ root := os.Getenv("BD2_REAL_GAMEDATA")
+ if root == "" {
+ t.Skip("BD2_REAL_GAMEDATA not configured")
+ }
+ d, err := LoadFieldResearch(root, "20260923193640", 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o := d.Objects[401]
+ if o.CollectionID != 103 || o.Type != 1 || o.Reward.Type != 4 || o.Reward.Count != 345 || len(o.Maps) != 1 || o.Maps[0] != 4 {
+ t.Fatalf("research table mismatch: %+v", o)
+ }
+ chars, err := LoadResearchCharacters(root, "20260923193640")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(chars) == 0 {
+ t.Fatal("research talents empty")
+ }
+}
diff --git a/go/internal/server/gamedata/food.go b/go/internal/server/gamedata/food.go
index 599650a..b3a623f 100644
--- a/go/internal/server/gamedata/food.go
+++ b/go/internal/server/gamedata/food.go
@@ -5,8 +5,6 @@ import (
"errors"
"fmt"
"math"
- "os"
- "path/filepath"
)
// Food is decoded from FoodTable, including materials so that the server can
@@ -19,24 +17,11 @@ type Food struct {
type FoodDesign struct{ Foods map[uint64]Food }
func LoadFoodDesign(root, version string) (*FoodDesign, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-food-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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()
return loadFoodDesign(db)
}
diff --git a/go/internal/server/gamedata/gacha.go b/go/internal/server/gamedata/gacha.go
index 70d5174..7f06de9 100644
--- a/go/internal/server/gamedata/gacha.go
+++ b/go/internal/server/gamedata/gacha.go
@@ -8,8 +8,6 @@ import (
"fmt"
"math"
"math/big"
- "os"
- "path/filepath"
"sort"
_ "modernc.org/sqlite"
@@ -971,24 +969,11 @@ func isPermanentEquipmentGachaGroup(raw []byte) bool {
// selected by a captured schedule. Resemara is owned by LoadInfiniteGacha and
// is intentionally omitted from both ordinary catalogs.
func ClassifyActiveGachaGroups(root, version string, groupIDs []uint64) (costume, equipment []uint64, err error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, nil, err
}
- dir, err := os.MkdirTemp("", "bd2-gacha-classify-")
- if err != nil {
- return nil, nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.db")
- if err := os.WriteFile(path, plain, 0o600); err != nil {
- return nil, nil, err
- }
- db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
- if err != nil {
- return nil, nil, err
- }
- defer db.Close()
+ defer release()
seen := map[uint64]bool{}
for _, id := range groupIDs {
if seen[id] {
@@ -1026,24 +1011,11 @@ func ClassifyActiveGachaGroups(root, version string, groupIDs []uint64) (costume
}
func LoadRegularCostumeGachaGroups(root, version string, groupIDs, stepUpGroupIDs []uint64) (*RegularGachaCatalog, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-regular-gacha-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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()
catalog := &RegularGachaCatalog{
Gachas: map[uint64]RegularGacha{}, characters: make(map[uint64]CharacterDesign),
groups: map[uint64]GachaGroupDesign{}, byGacha: map[uint64]uint64{}, fixed: map[uint64]GachaFixedDesign{}, grades: map[uint64]uint64{},
@@ -1218,6 +1190,15 @@ func loadGachaStepUp(db *sql.DB, groupID uint64) (GachaStepUpDesign, error) {
if id != step.Step || step.Step != uint64(len(out.Steps)+1) || step.GroupID == 0 || step.GachaID == 0 {
return GachaStepUpDesign{}, fmt.Errorf("gamedata: malformed step-up group %d row %d", groupID, id)
}
+ out.Steps = append(out.Steps, step)
+ }
+ if err := rows.Err(); err != nil {
+ return GachaStepUpDesign{}, err
+ }
+ if err := rows.Close(); err != nil {
+ return GachaStepUpDesign{}, err
+ }
+ for _, step := range out.Steps {
var child []byte
if err := db.QueryRow("SELECT ProtoBuf FROM GachaGroupTable WHERE id=?", step.GroupID).Scan(&child); err != nil {
return GachaStepUpDesign{}, fmt.Errorf("gamedata: step-up child group %d: %w", step.GroupID, err)
@@ -1228,10 +1209,6 @@ func loadGachaStepUp(db *sql.DB, groupID uint64) (GachaStepUpDesign, error) {
if len(types) != 1 || types[0] != 1 || len(subTypes) != 1 || subTypes[0] != 4 || len(tenIDs) != 1 || tenIDs[0] != step.GachaID {
return GachaStepUpDesign{}, fmt.Errorf("gamedata: step-up group %d child %d does not reference gacha %d", groupID, step.GroupID, step.GachaID)
}
- out.Steps = append(out.Steps, step)
- }
- if err := rows.Err(); err != nil {
- return GachaStepUpDesign{}, err
}
if len(out.Steps) == 0 {
return GachaStepUpDesign{}, fmt.Errorf("gamedata: step-up group %d is empty", groupID)
@@ -1398,13 +1375,6 @@ func (c *RegularGachaCatalog) loadGroupsAndFixed(db *sql.DB) error {
if !oneLoaded && !tenLoaded {
continue
}
- if group.CashProductGroupID != 0 && group.CashProductID != 0 {
- var err error
- group.CashRewards, err = loadGachaCashRewards(db, group)
- if err != nil {
- return err
- }
- }
c.groups[id] = group
if oneLoaded {
c.byGacha[group.OneTimeGachaID] = id
@@ -1416,7 +1386,17 @@ func (c *RegularGachaCatalog) loadGroupsAndFixed(db *sql.DB) error {
if err := rows.Err(); err != nil {
return err
}
- for _, group := range c.groups {
+ if err := rows.Close(); err != nil {
+ return err
+ }
+ for id, group := range c.groups {
+ if group.CashProductGroupID != 0 && group.CashProductID != 0 {
+ group.CashRewards, err = loadGachaCashRewards(db, group)
+ if err != nil {
+ return err
+ }
+ c.groups[id] = group
+ }
if group.FixedID == 0 {
continue
}
@@ -1658,24 +1638,11 @@ func NewInfiniteGachaDesignWithRates(count int, fiveStarIDs, fourStarIDs, threeS
}
func LoadInfiniteGachaForSchedules(root, version string, groups []uint64) (*InfiniteGachaDesign, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-gacha-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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()
infiniteGroupID, infiniteGachaID, err := loadInfiniteGachaIdentity(db, groups)
if err != nil {
diff --git a/go/internal/server/gamedata/gacha_infinite_metadata.go b/go/internal/server/gamedata/gacha_infinite_metadata.go
index 786008d..ba5587d 100644
--- a/go/internal/server/gamedata/gacha_infinite_metadata.go
+++ b/go/internal/server/gamedata/gacha_infinite_metadata.go
@@ -58,11 +58,21 @@ func loadInfiniteCashIdentity(db *sql.DB, rewardID uint64) (groupID, productID,
return 0, 0, 0, err
}
defer rows.Close()
+ var products [][]byte
for rows.Next() {
var raw []byte
if err = rows.Scan(&raw); err != nil {
return
}
+ products = append(products, raw)
+ }
+ if err = rows.Err(); err != nil {
+ return
+ }
+ if err = rows.Close(); err != nil {
+ return
+ }
+ for _, raw := range products {
box, e := optionalScalar(raw, 14)
if e != nil {
return 0, 0, 0, e
diff --git a/go/internal/server/gamedata/growth.go b/go/internal/server/gamedata/growth.go
index c46728e..4b05dc5 100644
--- a/go/internal/server/gamedata/growth.go
+++ b/go/internal/server/gamedata/growth.go
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
- "os"
- "path/filepath"
"sort"
_ "modernc.org/sqlite"
@@ -19,24 +17,11 @@ func CharacterGrowth(root, version string, charID int, level, exp uint64, materi
if charID <= 0 || level == 0 || len(materials) == 0 {
return 0, 0, nil, fmt.Errorf("gamedata: invalid character growth input")
}
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return 0, 0, nil, err
}
- dir, err := os.MkdirTemp("", "bd2-growth-")
- if err != nil {
- return 0, 0, nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.db")
- if err := os.WriteFile(path, plain, 0o600); err != nil {
- return 0, 0, nil, err
- }
- db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
- if err != nil {
- return 0, 0, nil, err
- }
- defer db.Close()
+ defer release()
var charProto []byte
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&charProto); err != nil {
diff --git a/go/internal/server/gamedata/hunting_ap.go b/go/internal/server/gamedata/hunting_ap.go
new file mode 100644
index 0000000..c9024ca
--- /dev/null
+++ b/go/internal/server/gamedata/hunting_ap.go
@@ -0,0 +1,50 @@
+package gamedata
+
+import (
+ "bd2server/internal/server/wire"
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+type HuntingAPDesign struct {
+ Max uint64
+ ResetSeconds int64
+}
+
+func LoadHuntingAPDesign(root, version string) (HuntingAPDesign, error) {
+ db, done, e := openStatDatabase(root, version)
+ if e != nil {
+ return HuntingAPDesign{}, e
+ }
+ defer done()
+ var raw []byte
+ if e = db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); e != nil {
+ return HuntingAPDesign{}, e
+ }
+ max, e := optionalScalar(raw, 56)
+ if e != nil {
+ return HuntingAPDesign{}, e
+ }
+ text, _, e := wire.Bytes(raw, 23)
+ if e != nil {
+ return HuntingAPDesign{}, e
+ }
+ parts := strings.Split(string(text), ":")
+ if max == 0 || max > 2147483647 || len(parts) != 3 {
+ return HuntingAPDesign{}, fmt.Errorf("gamedata: invalid hunting AP defaults")
+ }
+ var secs int64
+ for i, p := range parts {
+ n, e := strconv.ParseInt(p, 10, 64)
+ limit := int64(60)
+ if i == 0 {
+ limit = 24
+ }
+ if e != nil || n < 0 || n >= limit {
+ return HuntingAPDesign{}, fmt.Errorf("gamedata: invalid daily reset time")
+ }
+ secs = secs*60 + n
+ }
+ return HuntingAPDesign{Max: max, ResetSeconds: secs}, nil
+}
diff --git a/go/internal/server/gamedata/inventory_slots.go b/go/internal/server/gamedata/inventory_slots.go
index 953affc..09578d9 100644
--- a/go/internal/server/gamedata/inventory_slots.go
+++ b/go/internal/server/gamedata/inventory_slots.go
@@ -4,8 +4,6 @@ import (
"database/sql"
"errors"
"fmt"
- "os"
- "path/filepath"
_ "modernc.org/sqlite"
)
@@ -28,24 +26,11 @@ type InventorySlotDesign struct {
}
func LoadInventorySlotDesign(root, version string) (*InventorySlotDesign, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-inventory-slots-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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()
return loadInventorySlotDesign(db)
}
diff --git a/go/internal/server/gamedata/limited_costume.go b/go/internal/server/gamedata/limited_costume.go
index 16aca00..be030b9 100644
--- a/go/internal/server/gamedata/limited_costume.go
+++ b/go/internal/server/gamedata/limited_costume.go
@@ -1,11 +1,8 @@
package gamedata
import (
- "database/sql"
"errors"
"fmt"
- "os"
- "path/filepath"
_ "modernc.org/sqlite"
)
@@ -19,24 +16,11 @@ type LimitedCostumeCatalog struct {
}
func LoadLimitedCostumes(root, version string) (*LimitedCostumeCatalog, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-limited-costumes-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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()
rows, err := db.Query("SELECT id FROM LimitedCostumeTable ORDER BY id")
if err != nil {
return nil, err
@@ -51,19 +35,25 @@ func LoadLimitedCostumes(root, version string) (*LimitedCostumeCatalog, error) {
if costumeID == 0 {
return nil, errors.New("gamedata: limited costume has zero id")
}
- design, err := loadGachaCharacterDesign(db, costumeID)
- if err != nil {
- return nil, fmt.Errorf("gamedata: limited costume %d: %w", costumeID, err)
- }
catalog.ids = append(catalog.ids, costumeID)
- catalog.characters[costumeID] = design
}
if err := rows.Err(); err != nil {
return nil, err
}
+ // Release the single connection before character loading performs queries.
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
if len(catalog.ids) == 0 {
return nil, errors.New("gamedata: limited costume catalog is empty")
}
+ for _, costumeID := range catalog.ids {
+ design, err := loadGachaCharacterDesign(db, costumeID)
+ if err != nil {
+ return nil, fmt.Errorf("gamedata: limited costume %d: %w", costumeID, err)
+ }
+ catalog.characters[costumeID] = design
+ }
return catalog, nil
}
diff --git a/go/internal/server/gamedata/limited_costume_test.go b/go/internal/server/gamedata/limited_costume_test.go
new file mode 100644
index 0000000..43eb469
--- /dev/null
+++ b/go/internal/server/gamedata/limited_costume_test.go
@@ -0,0 +1,41 @@
+package gamedata
+
+import (
+ "os"
+ "testing"
+ "time"
+)
+
+func TestRealLimitedCostumesReleasesRowsBeforeCharacterQueries(t *testing.T) {
+ root := os.Getenv("BD2_REAL_GAMEDATA")
+ if root == "" {
+ t.Skip("BD2_REAL_GAMEDATA not configured")
+ }
+ type result struct {
+ catalog *LimitedCostumeCatalog
+ err error
+ }
+ done := make(chan result, 1)
+ go func() { c, e := LoadLimitedCostumes(root, "20260923193640"); done <- result{c, e} }()
+ select {
+ case r := <-done:
+ if r.err != nil {
+ t.Fatal(r.err)
+ }
+ ids := r.catalog.IDs()
+ if len(ids) == 0 {
+ t.Fatal("empty limited costume catalog")
+ }
+ for i, id := range ids {
+ if i > 0 && ids[i-1] >= id {
+ t.Fatal("unstable/duplicate membership order")
+ }
+ design, ok := r.catalog.Character(id)
+ if !ok || design.ID == 0 {
+ t.Fatalf("limited costume %d missing character design", id)
+ }
+ }
+ case <-time.After(15 * time.Second):
+ t.Fatal("limited costume nested queries blocked on retained result rows")
+ }
+}
diff --git a/go/internal/server/gamedata/login_passes.go b/go/internal/server/gamedata/login_passes.go
new file mode 100644
index 0000000..d7faa8b
--- /dev/null
+++ b/go/internal/server/gamedata/login_passes.go
@@ -0,0 +1,45 @@
+package gamedata
+
+import (
+ "fmt"
+ "sort"
+)
+
+type LoginPassReward struct {
+ ID, TicketID uint64
+ Free, Premium Reward
+}
+type LoginPassCatalog struct{ Groups map[uint64][]LoginPassReward }
+
+func LoadLoginPassCatalog(root, version string) (*LoginPassCatalog, error) {
+ db, done, err := openStatDatabase(root, version)
+ if err != nil {
+ return nil, err
+ }
+ defer done()
+ d := &LoginPassCatalog{Groups: map[uint64][]LoginPassReward{}}
+ err = readCashMetadata(db, "LoginPassTable", func(raw []byte) error {
+ v, e := cashScalars(raw, 5, 6, 1, 4, 3, 2, 9, 8, 7)
+ if e != nil {
+ return e
+ }
+ if v[0] == 0 || v[1] == 0 || v[2] == 0 || v[3] == 0 || v[5] == 0 || v[6] == 0 || v[8] == 0 {
+ return fmt.Errorf("gamedata: invalid login-pass reward")
+ }
+ d.Groups[v[0]] = append(d.Groups[v[0]], LoginPassReward{ID: v[1], TicketID: v[2], Free: Reward{Type: v[3], ID: v[4], Count: v[5]}, Premium: Reward{Type: v[6], ID: v[7], Count: v[8]}})
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ for group, rows := range d.Groups {
+ 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) || r.TicketID != rows[0].TicketID {
+ return nil, fmt.Errorf("gamedata: invalid login-pass sequence %d", group)
+ }
+ }
+ d.Groups[group] = rows
+ }
+ return d, nil
+}
diff --git a/go/internal/server/gamedata/missions.go b/go/internal/server/gamedata/missions.go
index bf03405..3128394 100644
--- a/go/internal/server/gamedata/missions.go
+++ b/go/internal/server/gamedata/missions.go
@@ -4,8 +4,6 @@ import (
"database/sql"
"fmt"
"math"
- "os"
- "path/filepath"
_ "modernc.org/sqlite"
)
@@ -56,24 +54,11 @@ type AchievementDesign struct {
// current database. Event missions intentionally remain outside this reader:
// their eligibility depends on an active server schedule, not just GameData.
func LoadMissionDesign(root, version string) (*MissionDesign, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-mission-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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()
return loadMissionDesignDB(db)
}
diff --git a/go/internal/server/gamedata/pack_detail.go b/go/internal/server/gamedata/pack_detail.go
index 3b119ab..8209c13 100644
--- a/go/internal/server/gamedata/pack_detail.go
+++ b/go/internal/server/gamedata/pack_detail.go
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
- "os"
- "path/filepath"
)
// PackDetailDesign identifies the monsters whose state is consumed by the
@@ -15,24 +13,11 @@ func LoadPackDetailDesign(root, version string, packID int) (PackDetailDesign, e
if packID <= 0 {
return PackDetailDesign{}, fmt.Errorf("gamedata: invalid detail pack %d", packID)
}
- 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 PackDetailDesign{}, err
}
- dir, err := os.MkdirTemp("", "bd2-pack-detail-")
- if err != nil {
- return PackDetailDesign{}, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "pack.db")
- if err := os.WriteFile(path, plain, 0o600); err != nil {
- return PackDetailDesign{}, err
- }
- db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
- if err != nil {
- return PackDetailDesign{}, err
- }
- defer db.Close()
+ defer release()
return loadPackDetailDesign(db)
}
diff --git a/go/internal/server/gamedata/pack_transition.go b/go/internal/server/gamedata/pack_transition.go
index a78e048..099a30f 100644
--- a/go/internal/server/gamedata/pack_transition.go
+++ b/go/internal/server/gamedata/pack_transition.go
@@ -1,10 +1,7 @@
package gamedata
import (
- "database/sql"
"fmt"
- "os"
- "path/filepath"
)
// PackTransition is the authoritative story edge in PackTable. It is kept
@@ -18,24 +15,11 @@ func LoadPackTransition(root, version string, packID int) (PackTransition, error
if packID <= 0 {
return PackTransition{}, fmt.Errorf("gamedata: invalid pack transition id %d", packID)
}
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return PackTransition{}, err
}
- dir, err := os.MkdirTemp("", "bd2-pack-transition-")
- if err != nil {
- return PackTransition{}, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.db")
- if err := os.WriteFile(path, plain, 0o600); err != nil {
- return PackTransition{}, err
- }
- db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
- if err != nil {
- return PackTransition{}, err
- }
- defer db.Close()
+ defer release()
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM PackTable WHERE id=?", packID).Scan(&raw); err != nil {
return PackTransition{}, fmt.Errorf("gamedata: pack %d: %w", packID, err)
diff --git a/go/internal/server/gamedata/preset.go b/go/internal/server/gamedata/preset.go
index 8711874..6b2397a 100644
--- a/go/internal/server/gamedata/preset.go
+++ b/go/internal/server/gamedata/preset.go
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
- "os"
- "path/filepath"
)
// PresetDesign is the normal party preset configuration in GameDefaultTable.
@@ -26,24 +24,11 @@ func (d PresetDesign) Validate() error {
}
func LoadPresetDesign(root, version string) (*PresetDesign, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-presets-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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 loadPresetDesign(db)
}
diff --git a/go/internal/server/gamedata/promotion.go b/go/internal/server/gamedata/promotion.go
index f68772a..a1360fc 100644
--- a/go/internal/server/gamedata/promotion.go
+++ b/go/internal/server/gamedata/promotion.go
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
- "os"
- "path/filepath"
)
// PromotionCost is one exact CharGrowthTable.ClassupItem{Type,Id,Count} row.
@@ -26,24 +24,11 @@ type PromotionGrowthResult struct {
// stage cap the client sends its class-up materials through CharGrowth, then
// detects a promotion by comparing the response character ID to the old ID.
func CharacterPromotion(root, version string, charID int, level, exp uint64) (uint64, []PromotionCost, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return 0, nil, err
}
- dir, err := os.MkdirTemp("", "bd2-promotion-")
- if err != nil {
- return 0, nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.db")
- if err := os.WriteFile(path, plain, 0o600); err != nil {
- return 0, nil, err
- }
- db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
- if err != nil {
- return 0, nil, err
- }
- defer db.Close()
+ defer release()
return characterPromotion(db, charID, level, exp)
}
@@ -54,24 +39,11 @@ func CharacterPromotion(root, version string, charID int, level, exp uint64) (ui
// CharTable.NextCharId chain; each prefix cost is then subtracted from the
// submitted resources and only the remainder contributes EXP.
func CharacterGrowthPromotions(root, version string, charID int, level, exp uint64, submitted []PromotionCost) (PromotionGrowthResult, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return PromotionGrowthResult{}, err
}
- dir, err := os.MkdirTemp("", "bd2-promotion-growth-")
- if err != nil {
- return PromotionGrowthResult{}, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.db")
- if err := os.WriteFile(path, plain, 0o600); err != nil {
- return PromotionGrowthResult{}, err
- }
- db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
- if err != nil {
- return PromotionGrowthResult{}, err
- }
- defer db.Close()
+ defer release()
return characterGrowthPromotions(db, charID, level, exp, submitted)
}
diff --git a/go/internal/server/gamedata/quest_collections_test.go b/go/internal/server/gamedata/quest_collections_test.go
index f69a8c0..80a7f96 100644
--- a/go/internal/server/gamedata/quest_collections_test.go
+++ b/go/internal/server/gamedata/quest_collections_test.go
@@ -13,6 +13,7 @@ func TestQuestCollectionsFollowChangedPackQuestAndItemIDs(t *testing.T) {
t.Fatal(err)
}
defer db.Close()
+ db.SetMaxOpenConns(1)
for _, sqlText := range []string{"CREATE TABLE QuestTable77(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE CollectionTable(id INTEGER,ProtoBuf BLOB)"} {
if _, err := db.Exec(sqlText); err != nil {
t.Fatal(err)
diff --git a/go/internal/server/gamedata/quest_difficulty.go b/go/internal/server/gamedata/quest_difficulty.go
index d19931a..c82c763 100644
--- a/go/internal/server/gamedata/quest_difficulty.go
+++ b/go/internal/server/gamedata/quest_difficulty.go
@@ -3,35 +3,16 @@ package gamedata
import (
"database/sql"
"fmt"
- "os"
- "path/filepath"
)
// LoadQuestDifficulties reads the pack-specific selectable difficulties;
// normal (zero) is the base story chain and is absent from this table.
func LoadQuestDifficulties(root, version string) (map[int]map[int]bool, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- file, err := os.CreateTemp("", "bd2-quest-difficulty-*.sqlite")
- if err != nil {
- return nil, err
- }
- name := file.Name()
- defer os.Remove(name)
- if _, err = file.Write(plain); err != nil {
- file.Close()
- return nil, err
- }
- if err = file.Close(); err != nil {
- return nil, err
- }
- db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(name)+"?mode=ro")
- if err != nil {
- return nil, err
- }
- defer db.Close()
+ defer release()
return loadQuestDifficultiesDB(db)
}
func loadQuestDifficultiesDB(db *sql.DB) (map[int]map[int]bool, error) {
diff --git a/go/internal/server/gamedata/quest_formation.go b/go/internal/server/gamedata/quest_formation.go
index 1da60c9..4b4f3fa 100644
--- a/go/internal/server/gamedata/quest_formation.go
+++ b/go/internal/server/gamedata/quest_formation.go
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
- "os"
- "path/filepath"
_ "modernc.org/sqlite"
)
@@ -48,29 +46,11 @@ func LoadQuestFormations(root, version string, packID int) (map[int]QuestFormati
if packID <= 0 {
return nil, fmt.Errorf("gamedata: invalid quest pack %d", packID)
}
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- f, err := os.CreateTemp("", "bd2-quest-formation-*.sqlite")
- if err != nil {
- return nil, fmt.Errorf("gamedata: create quest database: %w", err)
- }
- name := f.Name()
- defer os.Remove(name)
- if _, err = f.Write(plain); err == nil {
- err = f.Close()
- } else {
- _ = f.Close()
- }
- if err != nil {
- return nil, fmt.Errorf("gamedata: write quest database: %w", err)
- }
- db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(name)+"?mode=ro")
- if err != nil {
- return nil, fmt.Errorf("gamedata: open quest database: %w", err)
- }
- defer db.Close()
+ defer release()
return loadQuestFormationsDB(db, packID)
}
@@ -87,12 +67,26 @@ func loadQuestFormationsDB(db *sql.DB, packID int) (map[int]QuestFormation, erro
formations := make(map[int]QuestFormation)
charGroups := make(map[uint64][]QuestCharacterDesign)
storyGroups := make(map[uint64][]QuestCostumeDesign)
+ type formationRow struct {
+ id int
+ proto []byte
+ }
+ var sourceRows []formationRow
for rows.Next() {
- var questID int
- var proto []byte
- if err := rows.Scan(&questID, &proto); err != nil {
- return nil, fmt.Errorf("gamedata: scan QuestTable%d formation: %w", packID, err)
+ var row formationRow
+ if err := rows.Scan(&row.id, &row.proto); err != nil {
+ return nil, err
}
+ sourceRows = append(sourceRows, row)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ for _, row := range sourceRows {
+ questID, proto := row.id, row.proto
charGroupID, err := optionalScalar(proto, 4)
if err != nil {
return nil, fmt.Errorf("gamedata: quest %d char group: %w", questID, err)
@@ -188,12 +182,26 @@ func loadStoryCostumeGroup(db *sql.DB, groupID uint64) ([]QuestCostumeDesign, er
}
defer rows.Close()
var result []QuestCostumeDesign
+ type storyRow struct {
+ id int
+ proto []byte
+ }
+ var sourceRows []storyRow
for rows.Next() {
- var rowID int
- var proto []byte
- if err := rows.Scan(&rowID, &proto); err != nil {
+ var row storyRow
+ if err := rows.Scan(&row.id, &row.proto); err != nil {
return nil, err
}
+ sourceRows = append(sourceRows, row)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ for _, row := range sourceRows {
+ rowID, proto := row.id, row.proto
costumeID, err := requiredScalar(proto, 1)
if err != nil {
return nil, fmt.Errorf("story costume group %d row %d: %w", groupID, rowID, err)
diff --git a/go/internal/server/gamedata/quest_formation_test.go b/go/internal/server/gamedata/quest_formation_test.go
index fb3f6c4..594b006 100644
--- a/go/internal/server/gamedata/quest_formation_test.go
+++ b/go/internal/server/gamedata/quest_formation_test.go
@@ -17,6 +17,9 @@ func TestLoadQuestFormationsDB(t *testing.T) {
t.Fatal(err)
}
defer db.Close()
+ // Match production's single-connection database lease so nested formation
+ // and costume lookups require source rows to be released first.
+ db.SetMaxOpenConns(1)
for _, statement := range []string{
"CREATE TABLE QuestTable21 (id INTEGER PRIMARY KEY, ProtoBuf BLOB NOT NULL)",
"CREATE TABLE CharGroupTable (id INTEGER NOT NULL, GroupId INTEGER NOT NULL, ProtoBuf BLOB NOT NULL)",
diff --git a/go/internal/server/gamedata/quests.go b/go/internal/server/gamedata/quests.go
index d359990..1a9eca4 100644
--- a/go/internal/server/gamedata/quests.go
+++ b/go/internal/server/gamedata/quests.go
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
- "os"
- "path/filepath"
_ "modernc.org/sqlite"
)
@@ -26,24 +24,11 @@ func LoadQuestDesign(root, version string, packID int) (map[int]QuestDesign, err
if packID <= 0 {
return nil, fmt.Errorf("gamedata: invalid quest pack %d", packID)
}
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-quests-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "quests.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()
return loadQuestDesignDB(db, packID)
}
@@ -56,13 +41,29 @@ func loadQuestDesignDB(db *sql.DB, packID int) (map[int]QuestDesign, error) {
return nil, fmt.Errorf("gamedata: query QuestTable%d: %w", packID, err)
}
defer rows.Close()
- result := make(map[int]QuestDesign)
+ type questRow struct {
+ id int
+ proto []byte
+ }
+ var questRows []questRow
for rows.Next() {
- var id int
- var proto []byte
- if err := rows.Scan(&id, &proto); err != nil {
+ var row questRow
+ if err := rows.Scan(&row.id, &row.proto); err != nil {
return nil, err
}
+ questRows = append(questRows, row)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ // Collection lookups reuse the database's single connection after the
+ // quest result set is closed.
+ result := make(map[int]QuestDesign)
+ for _, row := range questRows {
+ id, proto := row.id, row.proto
if id <= 0 {
continue
}
diff --git a/go/internal/server/gamedata/random_box.go b/go/internal/server/gamedata/random_box.go
index 6122b64..adb3024 100644
--- a/go/internal/server/gamedata/random_box.go
+++ b/go/internal/server/gamedata/random_box.go
@@ -1,11 +1,8 @@
package gamedata
import (
- "database/sql"
"errors"
"fmt"
- "os"
- "path/filepath"
_ "modernc.org/sqlite"
)
@@ -24,24 +21,11 @@ type RandomBoxDesign struct {
// empty, weighted, malformed, cyclic, or resolves to a non-ItemDBInfo reward;
// callers then fail closed when a client tries to open it.
func LoadRandomBoxDesign(root, version string) (*RandomBoxDesign, error) {
- plain, err := ReadQuestDatabase(root, version)
+ db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
- dir, err := os.MkdirTemp("", "bd2-random-box-")
- if err != nil {
- return nil, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "common.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()
groups := map[uint64][]BattleReward{}
rows, err := db.Query("SELECT id, ProtoBuf FROM RewardGroupTable")
diff --git a/go/internal/server/gamedata/recruit_npc.go b/go/internal/server/gamedata/recruit_npc.go
index 0412723..1659a05 100644
--- a/go/internal/server/gamedata/recruit_npc.go
+++ b/go/internal/server/gamedata/recruit_npc.go
@@ -4,8 +4,6 @@ import (
"database/sql"
"fmt"
"math"
- "os"
- "path/filepath"
)
type RecruitNPC struct {
@@ -20,24 +18,11 @@ func LoadRecruitNPC(root, version string, packID int, npcID uint64) (RecruitNPC,
if packID <= 0 || npcID == 0 || npcID > math.MaxInt32 {
return RecruitNPC{}, fmt.Errorf("gamedata: invalid recruit NPC")
}
- 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 RecruitNPC{}, err
}
- dir, err := os.MkdirTemp("", "bd2-recruit-npc-")
- if err != nil {
- return RecruitNPC{}, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "pack.db")
- if err = os.WriteFile(path, plain, 0o600); err != nil {
- return RecruitNPC{}, err
- }
- db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
- if err != nil {
- return RecruitNPC{}, err
- }
- defer db.Close()
+ defer release()
npc, err := loadRecruitNPC(db, npcID)
if err != nil {
return RecruitNPC{}, err
diff --git a/go/internal/server/gamedata/reward_costume.go b/go/internal/server/gamedata/reward_costume.go
new file mode 100644
index 0000000..0f5812e
--- /dev/null
+++ b/go/internal/server/gamedata/reward_costume.go
@@ -0,0 +1,73 @@
+package gamedata
+
+// LoadRewardCostumeCatalog covers every installed costume, including event-only
+// rewards that never occur in a regular gacha pool.
+func LoadRewardCostumeCatalog(root, version string) (*RegularGachaCatalog, error) {
+ db, done, err := openStatDatabase(root, version)
+ if err != nil {
+ return nil, err
+ }
+ defer done()
+ chars, e := db.Query("SELECT uniqueCharId,ProtoBuf FROM CharTable")
+ if e != nil {
+ return nil, e
+ }
+ eligible := map[uint64]uint64{}
+ for chars.Next() {
+ var unique uint64
+ var raw []byte
+ if e = chars.Scan(&unique, &raw); e != nil {
+ chars.Close()
+ return nil, e
+ }
+ growth, e := optionalScalar(raw, 10)
+ if e != nil {
+ chars.Close()
+ return nil, e
+ }
+ temporary, e := optionalScalar(raw, 21)
+ if e != nil {
+ chars.Close()
+ return nil, e
+ }
+ if growth == 1 && temporary == 0 {
+ eligible[unique]++
+ }
+ }
+ if e = chars.Err(); e != nil {
+ chars.Close()
+ return nil, e
+ }
+ chars.Close()
+ rows, err := db.Query("SELECT id,useUniqueCharId FROM CostumeTable ORDER BY id")
+ if err != nil {
+ return nil, err
+ }
+ var ids []uint64
+ for rows.Next() {
+ var id, unique uint64
+ if err = rows.Scan(&id, &unique); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ if eligible[unique] == 1 {
+ ids = append(ids, id)
+ }
+ }
+ if err = rows.Err(); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ rows.Close()
+ out := &RegularGachaCatalog{characters: map[uint64]CharacterDesign{}}
+ for _, id := range ids {
+ design, e := loadGachaCharacterDesign(db, id)
+ if e != nil {
+ // Non-player prototype costumes lack a grantable growth boundary.
+ // They remain unavailable to Economy rather than inventing metadata.
+ continue
+ }
+ out.characters[id] = design
+ }
+ return out, nil
+}
diff --git a/go/internal/server/gamedata/reward_costume_test.go b/go/internal/server/gamedata/reward_costume_test.go
new file mode 100644
index 0000000..ec61c29
--- /dev/null
+++ b/go/internal/server/gamedata/reward_costume_test.go
@@ -0,0 +1,24 @@
+package gamedata
+
+import (
+ "os"
+ "testing"
+)
+
+func TestRewardCostumeCatalogInstalledSmoke(t *testing.T) {
+ root := os.Getenv("BD2_GAMEDATA_ROOT")
+ version := os.Getenv("BD2_GAMEDATA_VERSION")
+ if root == "" || version == "" {
+ t.Skip("explicit installed GameData integration environment required")
+ }
+ d, e := LoadRewardCostumeCatalog(root, version)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if len(d.characters) == 0 {
+ t.Fatal("empty catalog")
+ }
+ if _, ok := d.Character(60601); !ok {
+ t.Fatal("installed event costume omitted")
+ }
+}
diff --git a/go/internal/server/gamedata/reward_equipment.go b/go/internal/server/gamedata/reward_equipment.go
new file mode 100644
index 0000000..e97ed74
--- /dev/null
+++ b/go/internal/server/gamedata/reward_equipment.go
@@ -0,0 +1,38 @@
+package gamedata
+
+// LoadRewardEquipmentCatalog reads every equipment definition for rewards,
+// independently of which gacha banners the server has opened.
+func LoadRewardEquipmentCatalog(root, version string) (*EquipmentGachaCatalog, error) {
+ db, done, err := openStatDatabase(root, version)
+ if err != nil {
+ return nil, err
+ }
+ defer done()
+ rows, err := db.Query("SELECT id FROM EquipmentTable ORDER BY id")
+ if err != nil {
+ return nil, err
+ }
+ var ids []uint64
+ for rows.Next() {
+ var id uint64
+ if err = rows.Scan(&id); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ ids = append(ids, id)
+ }
+ if err = rows.Err(); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ if err = rows.Close(); err != nil {
+ return nil, err
+ }
+ c := &EquipmentGachaCatalog{equipment: map[uint64]EquipmentDesign{}}
+ for _, id := range ids {
+ if err = c.loadEquipmentTree(db, WeightedEquipment{ID: id}); err != nil {
+ return nil, err
+ }
+ }
+ return c, nil
+}
diff --git a/go/internal/server/gamedata/reward_equipment_test.go b/go/internal/server/gamedata/reward_equipment_test.go
new file mode 100644
index 0000000..778c1c6
--- /dev/null
+++ b/go/internal/server/gamedata/reward_equipment_test.go
@@ -0,0 +1,25 @@
+package gamedata
+
+import (
+ "os"
+ "testing"
+)
+
+func TestInstalledRewardEquipmentCatalog(t *testing.T) {
+ root := os.Getenv("BD2_REAL_GAMEDATA")
+ if root == "" {
+ t.Skip("explicit installed GameData integration environment required")
+ }
+ d, err := LoadRewardEquipmentCatalog(root, "20260923193640")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(d.equipment) == 0 {
+ t.Fatal("empty equipment catalog")
+ }
+ for id := range d.equipment {
+ if _, _, _, err := d.RollOptions(id); err != nil {
+ t.Fatalf("equipment %d: %v", id, err)
+ }
+ }
+}
diff --git a/go/internal/server/gamedata/reward_graph.go b/go/internal/server/gamedata/reward_graph.go
new file mode 100644
index 0000000..a5808dc
--- /dev/null
+++ b/go/internal/server/gamedata/reward_graph.go
@@ -0,0 +1,239 @@
+package gamedata
+
+import (
+ "crypto/rand"
+ "fmt"
+ "math"
+ "math/big"
+ "sort"
+ "sync"
+)
+
+// RewardGraph resolves random-box nodes; costume/equipment leaves retain
+// their design IDs for the owning grant domain.
+type RewardGraph struct {
+ mu sync.Mutex
+ boxes map[uint64]uint64
+ direct map[uint64]bool
+ groups map[uint64][]byte
+ sample func(uint64) (uint64, error)
+}
+
+func LoadRewardGraph(root, version string) (*RewardGraph, error) {
+ db, done, e := openStatDatabase(root, version)
+ if e != nil {
+ return nil, e
+ }
+ defer done()
+ g := &RewardGraph{boxes: map[uint64]uint64{}, direct: map[uint64]bool{}, groups: map[uint64][]byte{}}
+ g.sample = func(n uint64) (uint64, error) {
+ if n == 0 {
+ return 0, fmt.Errorf("gamedata: empty reward pool")
+ }
+ v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n))
+ if e != nil {
+ return 0, e
+ }
+ return v.Uint64(), nil
+ }
+ rows, e := db.Query("SELECT id,ProtoBuf FROM RandomBoxTable")
+ if e != nil {
+ return nil, e
+ }
+ for rows.Next() {
+ var id uint64
+ var raw []byte
+ if e = rows.Scan(&id, &raw); e != nil {
+ rows.Close()
+ return nil, e
+ }
+ gid, e := optionalScalar(raw, 9)
+ if e != nil {
+ rows.Close()
+ return nil, e
+ }
+ drop, err := optionalScalar(raw, 1)
+ if err != nil {
+ rows.Close()
+ return nil, err
+ }
+ g.direct[id] = drop == 1
+ g.boxes[id] = gid
+ }
+ e = rows.Err()
+ rows.Close()
+ if e != nil {
+ return nil, e
+ }
+ rows, e = db.Query("SELECT id,ProtoBuf FROM RewardGroupTable")
+ if e != nil {
+ return nil, e
+ }
+ for rows.Next() {
+ var id uint64
+ var raw []byte
+ if e = rows.Scan(&id, &raw); e != nil {
+ rows.Close()
+ return nil, e
+ }
+ g.groups[id] = append([]byte(nil), raw...)
+ }
+ e = rows.Err()
+ rows.Close()
+ return g, e
+}
+func (g *RewardGraph) SetSampler(f func(uint64) (uint64, error)) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.sample = f
+}
+func (g *RewardGraph) Resolve(rewards []BattleReward) ([]BattleReward, error) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ budget := uint64(100000)
+ totals := map[[2]uint64]uint64{}
+ seen := map[uint64]bool{}
+ var emit func(BattleReward) error
+ emit = func(r BattleReward) error {
+ if r.Count == 0 || r.Count > math.MaxInt32 || r.Type == 0 {
+ return fmt.Errorf("gamedata: invalid reward graph quantity/type")
+ }
+ if budget == 0 {
+ return fmt.Errorf("gamedata: reward graph operation limit")
+ }
+ budget--
+ if r.Type != 9 {
+ k := [2]uint64{r.Type, r.ID}
+ if totals[k] > math.MaxInt32-r.Count {
+ return fmt.Errorf("gamedata: reward graph overflow")
+ }
+ totals[k] += r.Count
+ return nil
+ }
+ if seen[r.ID] || len(seen) >= 32 {
+ return fmt.Errorf("gamedata: reward graph cycle")
+ }
+ gid, ok := g.boxes[r.ID]
+ if !ok {
+ return fmt.Errorf("gamedata: unknown random box %d", r.ID)
+ }
+ raw, ok := g.groups[gid]
+ if !ok {
+ return fmt.Errorf("gamedata: missing reward group %d", gid)
+ }
+ drop, e := optionalScalar(raw, 2)
+ if e != nil {
+ return e
+ }
+ count, e := optionalScalar(raw, 1)
+ if e != nil {
+ return e
+ }
+ children, e := eventGameRewards(raw, 6, 5, 4)
+ if e != nil || len(children) == 0 {
+ return fmt.Errorf("gamedata: invalid random box reward group %d", gid)
+ }
+ weights, e := packedInts(raw, 8)
+ if e != nil {
+ return e
+ }
+ if drop > 1 || len(weights) != len(children) || drop == 0 && count == 0 {
+ return fmt.Errorf("gamedata: invalid reward group selection mode")
+ }
+ seen[r.ID] = true
+ defer delete(seen, r.ID)
+ for n := uint64(0); n < r.Count; n++ {
+ if budget == 0 {
+ return fmt.Errorf("gamedata: reward graph operation limit")
+ }
+ budget--
+ if drop == 1 {
+ // Local server policy: direct components independently roll their GameData percentage.
+ for i, child := range children {
+ if weights[i] > 100 {
+ return fmt.Errorf("gamedata: direct reward percent exceeds 100")
+ }
+ x, e := g.sample(100)
+ if e != nil {
+ return e
+ }
+ if x >= 100 {
+ return fmt.Errorf("gamedata: sampler out of range")
+ }
+ if x >= weights[i] {
+ continue
+ }
+ if e = emit(child); e != nil {
+ return e
+ }
+ }
+ continue
+ }
+ sum := uint64(0)
+ for _, w := range weights {
+ if sum > math.MaxUint64-w {
+ return fmt.Errorf("gamedata: reward graph weight overflow")
+ }
+ sum += w
+ }
+ if sum == 0 || g.sample == nil {
+ return fmt.Errorf("gamedata: empty weighted reward pool")
+ }
+ for i := uint64(0); i < count; i++ {
+ if budget == 0 {
+ return fmt.Errorf("gamedata: reward graph operation limit")
+ }
+ x, e := g.sample(sum)
+ if e != nil {
+ return e
+ }
+ if x >= sum {
+ return fmt.Errorf("gamedata: random result outside reward pool")
+ }
+ for j, w := range weights {
+ if x < w {
+ if e = emit(children[j]); e != nil {
+ return e
+ }
+ break
+ }
+ x -= w
+ }
+ }
+ }
+ return nil
+ }
+ for _, r := range rewards {
+ if e := emit(r); e != nil {
+ return nil, e
+ }
+ }
+ var out []BattleReward
+ for k, v := range totals {
+ out = append(out, BattleReward{Type: k[0], ID: k[1], Count: v})
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Type != out[j].Type {
+ return out[i].Type < out[j].Type
+ }
+ return out[i].ID < out[j].ID
+ })
+ return out, nil
+}
+
+// ResolveGranted preserves manually opened boxes; only RbdDirect boxes expand.
+func (g *RewardGraph) ResolveGranted(rewards []BattleReward) ([]BattleReward, error) {
+ var out []BattleReward
+ for _, r := range rewards {
+ if r.Type == 9 && !g.direct[r.ID] {
+ out = append(out, r)
+ continue
+ }
+ expanded, e := g.Resolve([]BattleReward{r})
+ if e != nil {
+ return nil, e
+ }
+ out = append(out, expanded...)
+ }
+ return out, nil
+}
diff --git a/go/internal/server/gamedata/reward_graph_test.go b/go/internal/server/gamedata/reward_graph_test.go
new file mode 100644
index 0000000..b87732e
--- /dev/null
+++ b/go/internal/server/gamedata/reward_graph_test.go
@@ -0,0 +1,60 @@
+package gamedata
+
+import (
+ "bd2server/internal/server/wire"
+ "os"
+ "testing"
+)
+
+func group(drop, count uint64, rs []BattleReward, ws []uint64) []byte {
+ b := wire.AppendVarint(nil, 2, drop)
+ b = wire.AppendVarint(b, 1, count)
+ for _, r := range rs {
+ b = wire.AppendVarint(b, 6, r.Type)
+ b = wire.AppendVarint(b, 5, r.ID)
+ b = wire.AppendVarint(b, 4, r.Count)
+ }
+ for _, w := range ws {
+ b = wire.AppendVarint(b, 8, w)
+ }
+ return b
+}
+func TestRewardGraphWeightedNestedAndEquipmentLeaves(t *testing.T) {
+ g := &RewardGraph{boxes: map[uint64]uint64{1: 1, 2: 2}, groups: map[uint64][]byte{1: group(0, 1, []BattleReward{{9, 2, 1}, {8, 8, 2}}, []uint64{1, 3}), 2: group(1, 0, []BattleReward{{10, 10, 1}, {11, 11, 1}}, []uint64{100, 100})}, sample: func(uint64) (uint64, error) { return 0, nil }}
+ out, e := g.Resolve([]BattleReward{{9, 1, 2}})
+ if e != nil || len(out) != 2 || out[0].Type != 10 || out[0].Count != 2 || out[1].Type != 11 {
+ t.Fatalf("out=%v e=%v", out, e)
+ }
+ g.SetSampler(func(n uint64) (uint64, error) { return n - 1, nil })
+ out, e = g.Resolve([]BattleReward{{9, 1, 1}})
+ if e != nil || len(out) != 1 || out[0].Type != 8 || out[0].Count != 2 {
+ t.Fatalf("out=%v e=%v", out, e)
+ }
+}
+func TestRewardGraphCycleBadRandomAndOverflow(t *testing.T) {
+ g := &RewardGraph{boxes: map[uint64]uint64{1: 1}, groups: map[uint64][]byte{1: group(1, 0, []BattleReward{{9, 1, 1}}, nil)}}
+ if _, e := g.Resolve([]BattleReward{{9, 1, 1}}); e == nil {
+ t.Fatal("cycle accepted")
+ }
+ g.groups[1] = group(0, 1, []BattleReward{{8, 1, 1}}, []uint64{1})
+ g.sample = func(n uint64) (uint64, error) { return n, nil }
+ if _, e := g.Resolve([]BattleReward{{9, 1, 1}}); e == nil {
+ t.Fatal("bad random accepted")
+ }
+ if _, e := g.Resolve([]BattleReward{{8, 1, 2147483647}, {8, 1, 1}}); e == nil {
+ t.Fatal("overflow accepted")
+ }
+}
+func TestRewardGraphInstalledLoad(t *testing.T) {
+ root := os.Getenv("BD2_REAL_GAMEDATA")
+ if root == "" {
+ t.Skip("BD2_REAL_GAMEDATA not set")
+ }
+ g, e := LoadRewardGraph(root, "20260923193640")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if len(g.boxes) == 0 || len(g.groups) == 0 {
+ t.Fatal("empty graph")
+ }
+}
diff --git a/go/internal/server/gamedata/reward_skin.go b/go/internal/server/gamedata/reward_skin.go
new file mode 100644
index 0000000..623c5cf
--- /dev/null
+++ b/go/internal/server/gamedata/reward_skin.go
@@ -0,0 +1,28 @@
+package gamedata
+
+func LoadPrestigeSkins(root, version string) (map[uint64]uint64, error) {
+ db, done, e := openStatDatabase(root, version)
+ if e != nil {
+ return nil, e
+ }
+ defer done()
+ rows, e := db.Query("SELECT id,ProtoBuf FROM PrestigeSkinTable")
+ if e != nil {
+ return nil, e
+ }
+ defer rows.Close()
+ out := map[uint64]uint64{}
+ for rows.Next() {
+ var id uint64
+ var raw []byte
+ if e = rows.Scan(&id, &raw); e != nil {
+ return nil, e
+ }
+ costume, e := optionalScalar(raw, 1)
+ if e != nil {
+ return nil, e
+ }
+ out[id] = costume
+ }
+ return out, rows.Err()
+}
diff --git a/go/internal/server/gamedata/stat_design.go b/go/internal/server/gamedata/stat_design.go
index a7d2513..df61976 100644
--- a/go/internal/server/gamedata/stat_design.go
+++ b/go/internal/server/gamedata/stat_design.go
@@ -5,8 +5,6 @@ import (
"encoding/binary"
"fmt"
"math"
- "os"
- "path/filepath"
"bd2server/internal/server/wire"
_ "modernc.org/sqlite"
@@ -326,23 +324,5 @@ func fixed32Floats(proto []byte, number int) ([]float64, error) {
}
func openStatDatabase(root, version string) (*sql.DB, func(), error) {
- plain, err := ReadQuestDatabase(root, version)
- if err != nil {
- return nil, nil, err
- }
- dir, err := os.MkdirTemp("", "bd2-stats-")
- if err != nil {
- return nil, nil, err
- }
- path := filepath.Join(dir, "common.db")
- if err := os.WriteFile(path, plain, 0o600); 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, "common")
}
diff --git a/go/internal/server/gamedata/waypoint.go b/go/internal/server/gamedata/waypoint.go
index 57c289d..643f127 100644
--- a/go/internal/server/gamedata/waypoint.go
+++ b/go/internal/server/gamedata/waypoint.go
@@ -4,8 +4,6 @@ import (
"database/sql"
"fmt"
"math"
- "os"
- "path/filepath"
)
// WaypointPack uses the per-pack FieldWaypointTable and common PackTable.
@@ -43,24 +41,11 @@ func LoadWaypointPack(root, version string, packID uint64) (WaypointPack, error)
*target = values[0]
}
}
- 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 WaypointPack{}, err
}
- dir, err := os.MkdirTemp("", "bd2-waypoint-")
- if err != nil {
- return WaypointPack{}, err
- }
- defer os.RemoveAll(dir)
- path := filepath.Join(dir, "pack.db")
- if err = os.WriteFile(path, plain, 0600); err != nil {
- return WaypointPack{}, err
- }
- db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
- if err != nil {
- return WaypointPack{}, err
- }
- defer db.Close()
+ defer release()
design.Points, err = loadWaypointPoints(db)
return design, err
}
diff --git a/go/internal/server/gamedata/waypoint_cache_benchmark_test.go b/go/internal/server/gamedata/waypoint_cache_benchmark_test.go
new file mode 100644
index 0000000..0619cc0
--- /dev/null
+++ b/go/internal/server/gamedata/waypoint_cache_benchmark_test.go
@@ -0,0 +1,69 @@
+package gamedata
+
+import (
+ "os"
+ "testing"
+)
+
+// Opt-in performance verification, not a source for game design assertions.
+func BenchmarkInstalledWaypointPackWarm(b *testing.B) {
+ root := os.Getenv("BD2_REAL_GAMEDATA")
+ if root == "" {
+ b.Skip("BD2_REAL_GAMEDATA not configured")
+ }
+ if err := CloseDatabaseCache(); err != nil {
+ b.Fatal(err)
+ }
+ b.Cleanup(func() {
+ if err := CloseDatabaseCache(); err != nil {
+ b.Error(err)
+ }
+ })
+ if _, err := LoadWaypointPack(root, "20260923193640", 21); err != nil {
+ b.Fatal(err)
+ }
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ if _, err := LoadWaypointPack(root, "20260923193640", 21); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkInstalledWaypointPackCold(b *testing.B) {
+ root := os.Getenv("BD2_REAL_GAMEDATA")
+ if root == "" {
+ b.Skip("BD2_REAL_GAMEDATA not configured")
+ }
+ b.Cleanup(func() { _ = CloseDatabaseCache() })
+ for i := 0; i < b.N; i++ {
+ b.StopTimer()
+ if err := CloseDatabaseCache(); err != nil {
+ b.Fatal(err)
+ }
+ b.StartTimer()
+ if _, err := LoadWaypointPack(root, "20260923193640", 21); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkInstalledPackDetailWarm(b *testing.B) {
+ root := os.Getenv("BD2_REAL_GAMEDATA")
+ if root == "" {
+ b.Skip("BD2_REAL_GAMEDATA not configured")
+ }
+ if err := CloseDatabaseCache(); err != nil {
+ b.Fatal(err)
+ }
+ b.Cleanup(func() { _ = CloseDatabaseCache() })
+ if _, err := LoadPackDetailDesign(root, "20260923193640", 21); err != nil {
+ b.Fatal(err)
+ }
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ if _, err := LoadPackDetailDesign(root, "20260923193640", 21); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
diff --git a/go/internal/server/hunting/ap_refresh.go b/go/internal/server/hunting/ap_refresh.go
new file mode 100644
index 0000000..bf358a8
--- /dev/null
+++ b/go/internal/server/hunting/ap_refresh.go
@@ -0,0 +1,89 @@
+package hunting
+
+import (
+ "bd2server/internal/server/gamedata"
+ "bd2server/internal/server/stateio"
+ "bd2server/internal/server/versionconfig"
+ "bd2server/internal/server/wire"
+ "encoding/json"
+ "fmt"
+ "time"
+)
+
+type apClock struct {
+ Version string `json:"version"`
+ Next int64 `json:"next"`
+}
+
+func (s *Service) AttachAPRefresh(design gamedata.HuntingAPDesign) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if design.Max == 0 || design.Max > 2147483647 || design.ResetSeconds < 0 || design.ResetSeconds >= 86400 {
+ return fmt.Errorf("hunting: invalid AP refresh design")
+ }
+ s.apDesign = &design
+ s.now = time.Now
+ return nil
+}
+
+// The client clock is UTC+9. DailyResetTime is expressed in that clock.
+func (s *Service) refreshAP() error {
+ if s.apDesign == nil {
+ return nil
+ }
+ now := s.now().UnixMilli()
+ offset := (s.apDesign.ResetSeconds - 9*3600) * 1000
+ day := (now - offset) / 86400000
+ nextTime := (day+1)*86400000 + offset
+ b, e := s.storage.Load("huntingapclock")
+ if e != nil {
+ return e
+ }
+ clock := apClock{versionconfig.State(), nextTime}
+ if b != nil {
+ if e = stateio.RequireExactJSONObject(b, "version", "next"); e != nil {
+ return e
+ }
+ if e = json.Unmarshal(b, &clock); e != nil {
+ return e
+ }
+ if clock.Version != versionconfig.State() || clock.Next <= 0 {
+ return fmt.Errorf("hunting: invalid AP clock")
+ }
+ if now < clock.Next {
+ return nil
+ }
+ next := s.clone()
+ next.Free = s.apDesign.Max
+ if e = s.persist(next); e != nil {
+ return e
+ }
+ clock.Next = nextTime
+ }
+ raw, e := json.Marshal(clock)
+ if e != nil {
+ return e
+ }
+ return s.storage.Save("huntingapclock", raw)
+}
+
+func (s *Service) APChargeInfo() ([]byte, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if e := s.refreshAP(); e != nil {
+ return nil, e
+ }
+ raw, e := s.storage.Load("huntingapclock")
+ if e != nil || raw == nil {
+ return nil, e
+ }
+ var clock apClock
+ if e = json.Unmarshal(raw, &clock); e != nil {
+ return nil, e
+ }
+ b := wire.AppendVarint(nil, 1, uint64(clock.Next-86400000))
+ item := wire.AppendVarint(nil, 3, 21)
+ item = wire.AppendVarint(item, 4, s.state.Free)
+ b = wire.AppendBytes(b, 2, item)
+ return wire.AppendBytes(nil, 1, b), nil
+}
diff --git a/go/internal/server/hunting/dispatch.go b/go/internal/server/hunting/dispatch.go
index 600cca6..c8354e9 100644
--- a/go/internal/server/hunting/dispatch.go
+++ b/go/internal/server/hunting/dispatch.go
@@ -41,7 +41,7 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [
if strings.HasPrefix(path, "/HuntDispatch") {
return s.handleDispatch(path, req, session)
}
- return s.Handle(path, req)
+ return s.handleGroundSession(path, req, session)
}
func (s *Service) handleDispatch(path string, req []byte, session string) (int, []byte, bool, error) {
code := map[string]int{"/HuntDispatchInfo": 189, "/HuntDispatchStart": 190, "/HuntDispatchEnd": 191, "/HuntDispatchRewardPreview": 194, "/HuntDispatch": 0}[path]
@@ -50,6 +50,9 @@ func (s *Service) handleDispatch(path string, req []byte, session string) (int,
}
s.mu.Lock()
defer s.mu.Unlock()
+ if e := s.refreshAP(); e != nil {
+ return code, nil, true, e
+ }
seq, found, err := wire.Varint(req, 1)
if err != nil || !found || seq == 0 {
return code, nil, true, fmt.Errorf("hunting: missing sequence")
@@ -264,11 +267,30 @@ func (s *Service) handleDispatch(path string, req []byte, session string) (int,
ds.Jobs[jobKey] = job
out = wire.AppendBytes(out, 1, dispatchJobWire(job))
} else {
+ beforeFree, beforeBonus := s.state.Free, s.state.Bonus
bundle, e := s.dispatchGrant("dispatch:"+key, rewards)
if e != nil {
return code, nil, true, e
}
out = wire.AppendBytes(out, 1, bundle)
+ if s.state.Free >= beforeFree {
+ next.Free += s.state.Free - beforeFree
+ } else {
+ delta := beforeFree - s.state.Free
+ if next.Free < delta {
+ return code, nil, true, fmt.Errorf("hunting: AP grant conflict")
+ }
+ next.Free -= delta
+ }
+ if s.state.Bonus >= beforeBonus {
+ next.Bonus += s.state.Bonus - beforeBonus
+ } else {
+ delta := beforeBonus - s.state.Bonus
+ if next.Bonus < delta {
+ return code, nil, true, fmt.Errorf("hunting: AP grant conflict")
+ }
+ next.Bonus -= delta
+ }
}
if e = s.persist(next); e != nil {
return code, nil, true, e
@@ -316,6 +338,31 @@ func dispatchPreview(rs []gamedata.BattleReward) []byte {
return out
}
func (s *Service) dispatchGrant(identity string, rs []gamedata.BattleReward) ([]byte, error) {
+ if s.grant != nil {
+ var rewards []gamedata.Reward
+ var ap []gamedata.BattleReward
+ for _, r := range rs {
+ if r.Type == 21 || r.Type == 23 {
+ ap = append(ap, r)
+ } else {
+ rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count})
+ }
+ }
+ s.mu.Unlock()
+ bundle, err := s.grant(identity, rewards)
+ s.mu.Lock()
+ if err != nil {
+ return nil, err
+ }
+ bundle = append(bundle, dispatchPreview(ap)...)
+ for _, r := range ap {
+ v := wire.AppendVarint(nil, 3, r.Type)
+ v = wire.AppendVarint(v, 4, r.Count)
+ bundle = wire.AppendBytes(bundle, 1, v)
+ }
+ return bundle, nil
+ }
+
var currency []gamedata.Reward
var items []gamedata.BattleReward
for _, r := range rs {
diff --git a/go/internal/server/hunting/hunting.go b/go/internal/server/hunting/hunting.go
index d019c6e..d35adb5 100644
--- a/go/internal/server/hunting/hunting.go
+++ b/go/internal/server/hunting/hunting.go
@@ -13,6 +13,7 @@ import (
"math"
"strconv"
"sync"
+ "time"
)
const BattleMode uint64 = 5
@@ -39,6 +40,10 @@ type Service struct {
load func(int) (*gamedata.HuntingPack, error)
dispatchLoad func(uint64, uint64) (*gamedata.DispatchDesign, error)
dispatchEligibility func(*gamedata.DispatchDesign) error
+ eligibility func(int, uint64) error
+ grant func(string, []gamedata.Reward) ([]byte, error)
+ apDesign *gamedata.HuntingAPDesign
+ now func() time.Time
}
func Open(store stateio.Store, root, version string, inventory *player.Inventory, wallet *player.Wallet, currentPack func() (int, error), free, bonus uint64) (*Service, error) {
@@ -108,6 +113,9 @@ func Open(store stateio.Store, root, version string, inventory *player.Inventory
func (s *Service) HuntingAP() (uint64, uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
+ if e := s.refreshAP(); e != nil {
+ return 0, 0, e
+ }
return s.state.Free, s.state.Bonus, nil
}
func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) {
@@ -140,6 +148,7 @@ func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) {
return 387, nil, true, err
}
if len(d.Grounds) == 0 {
+ response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 5, id))
continue
}
info, err := s.info(int(id), d)
@@ -158,13 +167,18 @@ func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) {
if err != nil {
return 134, nil, true, err
}
- if len(d.Grounds) == 0 {
- return 134, nil, true, fmt.Errorf("hunting: pack has no hunting ground")
- }
if path == "/HuntingGroundInfo" {
+ // A catalog entry is not an account's active hunting run. The detail
+ // endpoint preserves an explicitly present empty message until entry.
+ if s.state.Packs[strconv.Itoa(int(pack))].Current == 0 {
+ return 134, wire.AppendBytes(nil, 1, nil), true, nil
+ }
b, err := s.info(int(pack), d)
return 134, wire.AppendBytes(nil, 1, b), true, err
}
+ if len(d.Grounds) == 0 {
+ return 110, nil, true, fmt.Errorf("hunting: pack has no hunting ground")
+ }
current, err := s.currentPack()
if err != nil || current != int(pack) {
return 110, nil, true, fmt.Errorf("hunting: enter pack is not current")
@@ -182,20 +196,17 @@ func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) {
if !ok {
return 110, nil, true, fmt.Errorf("hunting: unknown ground")
}
- first := d.Grounds[0].ID
- if id != first && id > st.Highest {
- previous := uint64(0)
- for _, v := range d.Grounds {
- if v.ID < id {
- previous = v.ID
- }
- }
- if previous == 0 || previous > st.Highest {
- return 110, nil, true, fmt.Errorf("hunting: ground difficulty locked")
+ if s.eligibility != nil {
+ if err := s.eligibility(int(pack), g.Difficulty); err != nil {
+ return 110, nil, true, err
}
+ } else if id != d.Grounds[0].ID {
+ return 110, nil, true, fmt.Errorf("hunting: main quest difficulty eligibility unavailable")
+ }
+ if st.Current != id {
+ st.Defeated = nil
}
st.Current, st.Auto = id, auto != 0
- st.Defeated = nil
next := s.clone()
next.Packs[strconv.Itoa(int(pack))] = st
if err := s.persist(next); err != nil {
@@ -209,6 +220,7 @@ func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) {
}
func (s *Service) info(pack int, d *gamedata.HuntingPack) ([]byte, error) {
st := s.state.Packs[strconv.Itoa(pack)]
+ initial := st.Current == 0
if st.Current == 0 {
st.Current = d.Grounds[0].ID
}
@@ -217,12 +229,24 @@ func (s *Service) info(pack int, d *gamedata.HuntingPack) ([]byte, error) {
return nil, fmt.Errorf("hunting: saved ground missing from GameData")
}
b := wire.AppendVarint(nil, 2, st.Current)
- b = wire.AppendVarint(b, 3, st.Highest)
+ if st.Highest != 0 {
+ b = wire.AppendVarint(b, 3, st.Highest)
+ }
b = wire.AppendVarint(b, 5, uint64(pack))
if st.Auto {
b = wire.AppendVarint(b, 1, 1)
}
- for _, m := range monstersWithState(d, g, st.Defeated) {
+ var monsterInfos [][]byte
+ if initial {
+ // The initial catalog lists ordinary monsters only. A boss is not an
+ // active account encounter simply because its design row exists.
+ for _, id := range g.Monsters {
+ monsterInfos = append(monsterInfos, monsterWire(d.Monsters[id], true))
+ }
+ } else {
+ monsterInfos = monstersWithState(d, g, st.Defeated)
+ }
+ for _, m := range monsterInfos {
b = wire.AppendBytes(b, 4, m)
}
return b, nil
@@ -247,16 +271,22 @@ func monsters(d *gamedata.HuntingPack, g gamedata.HuntingGround) [][]byte {
return monstersWithState(d, g, nil)
}
func monstersWithState(d *gamedata.HuntingPack, g gamedata.HuntingGround, defeated []uint64) [][]byte {
- ids := append(append([]uint64(nil), g.Monsters...), g.BossID)
- out := make([][]byte, 0, len(ids))
- for _, id := range ids {
- active := true
- for _, dead := range defeated {
- if dead == id {
- active = false
- }
- }
- out = append(out, monsterWire(d.Monsters[id], active))
+ dead := map[uint64]bool{}
+ for _, id := range defeated {
+ dead[id] = true
+ }
+ boss := len(g.Monsters) == 0
+ all := true
+ for _, id := range g.Monsters {
+ all = all && dead[id]
+ }
+ boss = boss || all
+ out := make([][]byte, 0, len(g.Monsters)+1)
+ for _, id := range g.Monsters {
+ out = append(out, monsterWire(d.Monsters[id], !dead[id]))
+ }
+ if boss {
+ out = append(out, monsterWire(d.Monsters[g.BossID], true))
}
return out
}
@@ -266,6 +296,9 @@ func (s *Service) ValidateBattle(pack int, mode, monster, deck uint64) error {
}
s.mu.Lock()
defer s.mu.Unlock()
+ if e := s.refreshAP(); e != nil {
+ return e
+ }
_, _, _, err := s.validate(pack, monster, deck)
return err
}
@@ -284,6 +317,17 @@ func (s *Service) validate(pack int, monster, deck uint64) (*gamedata.HuntingPac
member = member || id == monster
}
m := d.Monsters[monster]
+ if monster == g.BossID {
+ for _, id := range g.Monsters {
+ done := false
+ for _, dead := range st.Defeated {
+ done = done || dead == id
+ }
+ if !done {
+ return nil, g, m, fmt.Errorf("hunting: boss locked until ordinary monsters are defeated")
+ }
+ }
+ }
for _, dead := range st.Defeated {
if dead == monster {
return nil, g, m, fmt.Errorf("hunting: defeated monster requires reentry")
@@ -314,8 +358,16 @@ func (s *Service) CompleteBattle(pack int, mode, monster, deck uint64, receipt s
if receipt == "" {
return nil, nil, fmt.Errorf("hunting: missing battle receipt")
}
- if s.state.Receipts[receipt] {
- return nil, nil, nil
+ fingerprint := fmt.Sprintf("%d:%d:%d:%d", pack, mode, monster, deck)
+ ledger, err := s.loadBattleReceipts()
+ if err != nil {
+ return nil, nil, err
+ }
+ if saved, ok := ledger[receipt]; ok {
+ if saved.Fingerprint != fingerprint {
+ return nil, nil, fmt.Errorf("hunting: battle receipt conflict")
+ }
+ return saved.Bundle, saved.Monsters, nil
}
d, g, m, err := s.validate(pack, monster, deck)
if err != nil {
@@ -332,15 +384,30 @@ func (s *Service) CompleteBattle(pack int, mode, monster, deck uint64, receipt s
stack = append(stack, r)
}
}
- if _, err := s.wallet.GrantQuestOnce(identity+":currency", currency); err != nil {
- return nil, nil, err
- }
- items, err := s.inventory.GrantOnce(identity+":items", stack)
- if err != nil {
- return nil, nil, err
- }
- if len(items) == 0 {
- items = s.inventory.GrantedItems(identity + ":items")
+ var grantedBundle []byte
+ var items []player.Item
+ if s.grant != nil {
+ rs := make([]gamedata.Reward, len(rewards))
+ for i, r := range rewards {
+ rs[i] = gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}
+ }
+ s.mu.Unlock()
+ grantedBundle, err = s.grant(identity, rs)
+ s.mu.Lock()
+ if err != nil {
+ return nil, nil, err
+ }
+ } else {
+ if _, err := s.wallet.GrantQuestOnce(identity+":currency", currency); err != nil {
+ return nil, nil, err
+ }
+ items, err = s.inventory.GrantOnce(identity+":items", stack)
+ if err != nil {
+ return nil, nil, err
+ }
+ if len(items) == 0 {
+ items = s.inventory.GrantedItems(identity + ":items")
+ }
}
next := s.clone()
cost := d.NormalAP
@@ -353,21 +420,21 @@ func (s *Service) CompleteBattle(pack int, mode, monster, deck uint64, receipt s
next.Bonus -= cost - next.Free
next.Free = 0
}
+ st := next.Packs[strconv.Itoa(pack)]
if monster == g.BossID {
- st := next.Packs[strconv.Itoa(pack)]
if st.Highest < g.ID {
st.Highest = g.ID
}
- next.Packs[strconv.Itoa(pack)] = st
+ st.Defeated = nil
+ } else {
+ st.Defeated = append(append([]uint64(nil), st.Defeated...), monster)
}
- next.Receipts[receipt] = true
- st := next.Packs[strconv.Itoa(pack)]
- st.Defeated = append(append([]uint64(nil), st.Defeated...), monster)
next.Packs[strconv.Itoa(pack)] = st
- if err := s.persist(next); err != nil {
- return nil, nil, err
- }
+ next.Receipts[receipt] = true
var bundle []byte
+ if s.grant != nil {
+ bundle = grantedBundle
+ }
for _, item := range items {
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item))
view := wire.AppendVarint(nil, 2, item.ID)
@@ -376,11 +443,36 @@ func (s *Service) CompleteBattle(pack int, mode, monster, deck uint64, receipt s
bundle = wire.AppendBytes(bundle, 6, view)
}
for _, r := range currency {
+ if s.grant != nil {
+ break
+ }
b := wire.AppendVarint(nil, 3, r.Type)
b = wire.AppendVarint(b, 4, r.Count)
bundle = wire.AppendBytes(bundle, 1, b)
}
- return bundle, [][]byte{monsterWire(m, false)}, nil
+ updates := [][]byte{monsterWire(m, false)}
+ if monster == g.BossID {
+ for _, id := range g.Monsters {
+ updates = append(updates, monsterWire(d.Monsters[id], true))
+ }
+ } else if len(st.Defeated) == len(g.Monsters) {
+ updates = append(updates, monsterWire(d.Monsters[g.BossID], true))
+ }
+ if err := s.persist(next); err != nil {
+ return nil, nil, err
+ }
+ latest, err := s.loadBattleReceipts()
+ if err != nil {
+ return nil, nil, err
+ }
+ for key, value := range latest {
+ ledger[key] = value
+ }
+ ledger[receipt] = battleReceipt{Fingerprint: fingerprint, Bundle: bundle, Monsters: updates}
+ if err := s.saveBattleReceipts(ledger); err != nil {
+ return nil, nil, err
+ }
+ return bundle, updates, nil
}
func (s *Service) clone() snapshot {
next := s.state
diff --git a/go/internal/server/hunting/hunting_test.go b/go/internal/server/hunting/hunting_test.go
index 0bda5a4..be7caa0 100644
--- a/go/internal/server/hunting/hunting_test.go
+++ b/go/internal/server/hunting/hunting_test.go
@@ -5,9 +5,86 @@ import (
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
+ "bytes"
+ "fmt"
"testing"
+ "time"
)
+func TestHuntingCatalogPlaceholdersAndDetailRequireEnteredRun(t *testing.T) {
+ store := stateio.NewMemory()
+ inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
+ wallet, _ := player.OpenWallet(store, player.Currency{})
+ s, err := Open(store, "", "", inv, wallet, func() (int, error) { return 73, nil }, 20, 5)
+ if err != nil {
+ t.Fatal(err)
+ }
+ d := &gamedata.HuntingPack{Grounds: []gamedata.HuntingGround{{ID: 9, MapID: 123, BossID: 41, Monsters: []uint64{31, 32, 33, 34, 35}}}, Monsters: map[uint64]gamedata.HuntingMonster{}}
+ for _, id := range []uint64{31, 32, 33, 34, 35, 41} {
+ d.Monsters[id] = gamedata.HuntingMonster{ID: id, Decks: []uint64{id + 100}}
+ }
+ s.load = func(pack int) (*gamedata.HuntingPack, error) {
+ switch pack {
+ case 73:
+ return d, nil
+ case 84, 85:
+ return &gamedata.HuntingPack{}, nil
+ default:
+ return nil, fmt.Errorf("unknown pack")
+ }
+ }
+ list := wire.AppendVarint(nil, 1, 1)
+ for _, id := range []uint64{84, 73, 85} {
+ list = wire.AppendVarint(list, 2, id)
+ }
+ code, response, ok, err := s.Handle("/HuntingGroundInfoList", list)
+ if err != nil || !ok || code != 387 {
+ t.Fatalf("list %d %v %v", code, ok, err)
+ }
+ var entries [][]byte
+ if err := wire.Walk(response, func(f wire.Field) error {
+ if f.Number == 1 {
+ entries = append(entries, f.Value)
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 3 || !bytes.Equal(entries[0], wire.AppendVarint(nil, 5, 84)) || !bytes.Equal(entries[2], wire.AppendVarint(nil, 5, 85)) {
+ t.Fatalf("catalog lost ordered pack placeholders: %x", entries)
+ }
+ current, _, _ := wire.Varint(entries[1], 2)
+ monsters := 0
+ _ = wire.Walk(entries[1], func(f wire.Field) error {
+ if f.Number == 4 {
+ monsters++
+ }
+ return nil
+ })
+ if current != 9 || monsters != 5 {
+ t.Fatalf("catalog current=%d monsters=%d", current, monsters)
+ }
+ for _, id := range []uint64{73, 84} {
+ req := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, id)
+ _, detail, _, err := s.Handle("/HuntingGroundInfo", req)
+ if err != nil || !bytes.Equal(detail, wire.AppendBytes(nil, 1, nil)) {
+ t.Fatalf("unentered pack %d returned active state: %x %v", id, detail, err)
+ }
+ }
+ if len(s.state.Packs) != 0 {
+ t.Fatal("query created hunting progress")
+ }
+ enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 73), 3, 9)
+ if _, _, _, err := s.Handle("/HuntingGroundEnter", enter); err != nil {
+ t.Fatal(err)
+ }
+ _, detail, _, err := s.Handle("/HuntingGroundInfo", wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, 73))
+ active, found, _ := wire.Bytes(detail, 1)
+ if err != nil || !found || len(active) == 0 {
+ t.Fatalf("entered detail missing: %x %v", detail, err)
+ }
+}
+
func TestHuntingRepeatEnterReceiptAPAndDefeated(t *testing.T) {
store := stateio.NewMemory()
inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
@@ -68,14 +145,26 @@ func TestHuntingRepeatEnterReceiptAPAndDefeated(t *testing.T) {
if err := enter(9); err != nil {
t.Fatal(err)
}
+ if _, _, err := s.CompleteBattle(73, 5, 31, 81, "sessionB:2"); err == nil {
+ t.Fatal("same difficulty entry reset round")
+ }
+ if _, _, err := s.CompleteBattle(73, 5, 41, 91, "sessionB:boss"); err != nil {
+ t.Fatal(err)
+ }
if _, _, err := s.CompleteBattle(73, 5, 31, 81, "sessionB:2"); err != nil {
t.Fatal(err)
}
- if len(inv.All()) != 2 || inv.All()[0].Count+inv.All()[1].Count != 4 {
+ var normal uint64
+ for _, item := range inv.All() {
+ if item.ID == 999 {
+ normal += item.Count
+ }
+ }
+ if normal != 4 {
t.Fatalf("repeated rewards=%+v", inv.All())
}
free, _, _ = s.HuntingAP()
- if free != 14 {
+ if free != 7 {
t.Fatalf("repeat AP=%d", free)
}
if _, _, err := s.CompleteBattle(73, 5, 41, 91, "sessionB:3"); err != nil {
@@ -85,3 +174,132 @@ func TestHuntingRepeatEnterReceiptAPAndDefeated(t *testing.T) {
t.Fatal("boss did not unlock next ground")
}
}
+
+func TestHuntingStageLoopAndBattleReplay(t *testing.T) {
+ store := stateio.NewMemory()
+ inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
+ wallet, _ := player.OpenWallet(store, player.Currency{})
+ s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 100, 0)
+ d := &gamedata.HuntingPack{NormalAP: 1, BossAP: 1, Grounds: []gamedata.HuntingGround{{ID: 1, MapID: 13, BossID: 3, Monsters: []uint64{1, 2}}}, Monsters: map[uint64]gamedata.HuntingMonster{}}
+ for _, id := range []uint64{1, 2, 3} {
+ typ := uint64(0)
+ if id == 3 {
+ typ = 1
+ }
+ d.Monsters[id] = gamedata.HuntingMonster{ID: id, Type: typ, Decks: []uint64{id}, Rewards: map[uint64][]gamedata.BattleReward{id: {{Type: 4, Count: 10}}}}
+ }
+ s.load = func(int) (*gamedata.HuntingPack, error) { return d, nil }
+ if _, e := s.EnsureForPack(1); e != nil {
+ t.Fatal(e)
+ }
+ if e := s.ValidateBattle(1, 5, 3, 3); e == nil {
+ t.Fatal("early boss accepted")
+ }
+ if _, _, e := s.CompleteBattle(1, 5, 1, 1, "a:1"); e != nil {
+ t.Fatal(e)
+ }
+ req := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1), 3, 1)
+ if _, _, _, e := s.HandleSession("/HuntingGroundEnter", req, "a"); e != nil {
+ t.Fatal(e)
+ }
+ if e := s.ValidateBattle(1, 5, 1, 1); e == nil {
+ t.Fatal("enter reset partial stage")
+ }
+ bundle, updates, e := s.CompleteBattle(1, 5, 2, 2, "a:3")
+ if e != nil || len(updates) != 2 {
+ t.Fatalf("boss transition %v %+v", e, updates)
+ }
+ again, replay, e := s.CompleteBattle(1, 5, 2, 2, "a:3")
+ if e != nil || !bytes.Equal(bundle, again) || len(replay) != 2 {
+ t.Fatal("battle replay lost response")
+ }
+ if _, _, e = s.CompleteBattle(1, 5, 3, 3, "a:3"); e == nil {
+ t.Fatal("battle receipt conflict accepted")
+ }
+ _, updates, e = s.CompleteBattle(1, 5, 3, 3, "a:4")
+ if e != nil || len(updates) != 3 || len(s.state.Packs["1"].Defeated) != 0 {
+ t.Fatalf("round reset %v %v", e, updates)
+ }
+ if e = s.ValidateBattle(1, 5, 1, 1); e != nil {
+ t.Fatal(e)
+ }
+ free, _, _ := s.HuntingAP()
+ if free != 97 || wallet.Snapshot().Gold != 30 {
+ t.Fatal("duplicate AP/reward")
+ }
+}
+func TestHuntingAPExchangeReplayAndOverflow(t *testing.T) {
+ store := stateio.NewMemory()
+ inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
+ wallet, _ := player.OpenWallet(store, player.Currency{})
+ s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 10, 2)
+ cost := []gamedata.Reward{{Type: 21, Count: 4}}
+ reward := []gamedata.Reward{{Type: 23, Count: 3}}
+ if e := s.ExchangeAPOnce("x", cost, reward); e != nil {
+ t.Fatal(e)
+ }
+ if e := s.ExchangeAPOnce("x", cost, reward); e != nil {
+ t.Fatal(e)
+ }
+ free, bonus, _ := s.HuntingAP()
+ if free != 6 || bonus != 5 {
+ t.Fatal("AP replay")
+ }
+ if e := s.ExchangeAPOnce("x", nil, reward); e == nil {
+ t.Fatal("identity conflict")
+ }
+ if e := s.CanExchangeAP(nil, []gamedata.Reward{{Type: 21, Count: 2147483647}}); e == nil {
+ t.Fatal("AP overflow")
+ }
+}
+
+func TestHuntingRewardAPCallbackDoesNotDeadlock(t *testing.T) {
+ store := stateio.NewMemory()
+ inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
+ wallet, _ := player.OpenWallet(store, player.Currency{})
+ s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 10, 0)
+ s.load = func(int) (*gamedata.HuntingPack, error) {
+ return &gamedata.HuntingPack{NormalAP: 1, Grounds: []gamedata.HuntingGround{{ID: 1, BossID: 2, Monsters: []uint64{1}}}, Monsters: map[uint64]gamedata.HuntingMonster{1: {ID: 1, Decks: []uint64{1}, Rewards: map[uint64][]gamedata.BattleReward{1: {{Type: 21, Count: 3}}}}, 2: {ID: 2, Decks: []uint64{2}}}}, nil
+ }
+ s.AttachRewards(func(id string, _ []gamedata.Reward) ([]byte, error) {
+ return nil, s.ExchangeAPOnce(id, nil, []gamedata.Reward{{Type: 21, Count: 3}})
+ })
+ s.EnsureForPack(1)
+ done := make(chan error, 1)
+ go func() { _, _, e := s.CompleteBattle(1, 5, 1, 1, "apreward"); done <- e }()
+ select {
+ case e := <-done:
+ if e != nil {
+ t.Fatal(e)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("reward callback deadlocked")
+ }
+ free, _, _ := s.HuntingAP()
+ if free != 12 {
+ t.Fatalf("AP grant lost %d", free)
+ }
+}
+
+func TestHuntingAPDailyRefreshPreservesBonus(t *testing.T) {
+ store := stateio.NewMemory()
+ inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
+ wallet, _ := player.OpenWallet(store, player.Currency{})
+ s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 4, 7)
+ if e := s.AttachAPRefresh(gamedata.HuntingAPDesign{Max: 90, ResetSeconds: 9 * 3600}); e != nil {
+ t.Fatal(e)
+ }
+ now := time.Date(2026, 10, 4, 23, 59, 0, 0, time.UTC)
+ s.now = func() time.Time { return now }
+ s.HuntingAP()
+ now = now.Add(2 * time.Minute)
+ free, bonus, e := s.HuntingAP()
+ if e != nil || free != 90 || bonus != 7 {
+ t.Fatalf("refresh %d %d %v", free, bonus, e)
+ }
+ s.state.Free = 12
+ free, _, _ = s.HuntingAP()
+ if free != 12 {
+ t.Fatal("refreshed same day twice")
+ }
+}
diff --git a/go/internal/server/hunting/progress.go b/go/internal/server/hunting/progress.go
new file mode 100644
index 0000000..dc00a01
--- /dev/null
+++ b/go/internal/server/hunting/progress.go
@@ -0,0 +1,224 @@
+package hunting
+
+import (
+ "bd2server/internal/server/gamedata"
+ "bd2server/internal/server/stateio"
+ "bd2server/internal/server/versionconfig"
+ "bd2server/internal/server/wire"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "strconv"
+)
+
+type battleReceipt struct {
+ Fingerprint string
+ Bundle []byte
+ Monsters [][]byte
+}
+type battleLedger struct {
+ Version string `json:"version"`
+ Receipts map[string]battleReceipt `json:"receipts"`
+}
+
+func (s *Service) loadBattleReceipts() (map[string]battleReceipt, error) {
+ b, e := s.storage.Load("huntingbattle")
+ if e != nil {
+ return nil, e
+ }
+ if b == nil {
+ return map[string]battleReceipt{}, nil
+ }
+ if e = stateio.RequireExactJSONObject(b, "version", "receipts"); e != nil {
+ return nil, e
+ }
+ var l battleLedger
+ d := json.NewDecoder(bytes.NewReader(b))
+ d.DisallowUnknownFields()
+ if e = d.Decode(&l); e != nil {
+ return nil, e
+ }
+ if l.Version != versionconfig.State() || l.Receipts == nil {
+ return nil, fmt.Errorf("hunting: invalid battle ledger")
+ }
+ return l.Receipts, nil
+}
+func (s *Service) saveBattleReceipts(rs map[string]battleReceipt) error {
+ b, e := json.Marshal(battleLedger{versionconfig.State(), rs})
+ if e != nil {
+ return e
+ }
+ return s.storage.Save("huntingbattle", b)
+}
+func (s *Service) AttachEligibility(check func(int, uint64) error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.eligibility = check
+}
+func (s *Service) AttachRewards(grant func(string, []gamedata.Reward) ([]byte, error)) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.grant = grant
+}
+
+// EnsureForPack initializes the ordinary stage when the world loads a pack.
+func (s *Service) EnsureForPack(pack int) ([]byte, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ d, e := s.load(pack)
+ if e != nil {
+ return nil, e
+ }
+ if len(d.Grounds) == 0 {
+ return nil, nil
+ }
+ st := s.state.Packs[strconv.Itoa(pack)]
+ if st.Current == 0 {
+ st.Current = d.Grounds[0].ID
+ next := s.clone()
+ next.Packs[strconv.Itoa(pack)] = st
+ if e = s.persist(next); e != nil {
+ return nil, e
+ }
+ }
+ return s.info(pack, d)
+}
+func (s *Service) SnapshotForPack(pack int) ([]byte, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ d, e := s.load(pack)
+ if e != nil {
+ return nil, e
+ }
+ if len(d.Grounds) == 0 {
+ return nil, nil
+ }
+ return s.info(pack, d)
+}
+
+// Ground mutations have their own persisted session+sequence replay ledger.
+func (s *Service) handleGroundSession(path string, req []byte, session string) (int, []byte, bool, error) {
+ if path != "/HuntingGroundEnter" {
+ return s.Handle(path, req)
+ }
+ seq, found, e := wire.Varint(req, 1)
+ if e != nil || !found || seq == 0 {
+ return 110, nil, true, fmt.Errorf("hunting: missing sequence")
+ }
+ key := "enter:" + session + ":" + strconv.FormatUint(seq, 10)
+ rs, e := s.loadBattleReceipts()
+ if e != nil {
+ return 110, nil, true, e
+ }
+ fp := fmt.Sprintf("%x", req)
+ if r, ok := rs[key]; ok {
+ if r.Fingerprint != fp {
+ return 110, nil, true, fmt.Errorf("hunting: enter sequence conflict")
+ }
+ return 110, r.Bundle, true, nil
+ }
+ code, out, ok, e := s.Handle(path, req)
+ if e != nil || !ok {
+ return code, out, ok, e
+ }
+ rs[key] = battleReceipt{Fingerprint: fp, Bundle: out}
+ if e = s.saveBattleReceipts(rs); e != nil {
+ return code, nil, true, e
+ }
+ return code, out, ok, nil
+}
+func (s *Service) reload() error {
+ b, e := s.storage.Load("hunting")
+ if e != nil {
+ return e
+ }
+ if b == nil {
+ return nil
+ }
+ var st snapshot
+ if e = json.Unmarshal(b, &st); e != nil {
+ return e
+ }
+ s.state = st
+ return nil
+}
+func (s *Service) CanExchangeAP(costs, rewards []gamedata.Reward) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if e := s.reload(); e != nil {
+ return e
+ }
+ if e := s.refreshAP(); e != nil {
+ return e
+ }
+ _, e := s.exchangeAP(costs, rewards)
+ return e
+}
+func (s *Service) exchangeAP(costs, rewards []gamedata.Reward) (snapshot, error) {
+ st := s.clone()
+ apply := func(rs []gamedata.Reward, cost bool) error {
+ for _, r := range rs {
+ if (r.Type != 21 && r.Type != 23) || r.ID != 0 || r.Count == 0 {
+ return fmt.Errorf("hunting: invalid AP exchange")
+ }
+ p := &st.Free
+ if r.Type == 23 {
+ p = &st.Bonus
+ }
+ if cost {
+ if *p < r.Count {
+ return fmt.Errorf("hunting: insufficient AP")
+ }
+ *p -= r.Count
+ } else {
+ if r.Count > 2147483647-*p {
+ return fmt.Errorf("hunting: AP overflow")
+ }
+ *p += r.Count
+ }
+ }
+ return nil
+ }
+ if e := apply(costs, true); e != nil {
+ return st, e
+ }
+ return st, apply(rewards, false)
+}
+func (s *Service) ExchangeAPOnce(identity string, costs, rewards []gamedata.Reward) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if identity == "" {
+ return fmt.Errorf("hunting: missing AP identity")
+ }
+ if e := s.reload(); e != nil {
+ return e
+ }
+ key := "apexchange:" + identity
+ fpb, e := json.Marshal([][]gamedata.Reward{costs, rewards})
+ if e != nil {
+ return e
+ }
+ rs, e := s.loadBattleReceipts()
+ if e != nil {
+ return e
+ }
+ fp := string(fpb)
+ if r, ok := rs[key]; ok {
+ if r.Fingerprint != fp {
+ return fmt.Errorf("hunting: AP identity conflict")
+ }
+ return nil
+ }
+ if e := s.refreshAP(); e != nil {
+ return e
+ }
+ st, e := s.exchangeAP(costs, rewards)
+ if e != nil {
+ return e
+ }
+ if e = s.persist(st); e != nil {
+ return e
+ }
+ rs[key] = battleReceipt{Fingerprint: fp}
+ return s.saveBattleReceipts(rs)
+}
diff --git a/go/internal/server/missions/events.go b/go/internal/server/missions/events.go
index 2600cdd..e9f4431 100644
--- a/go/internal/server/missions/events.go
+++ b/go/internal/server/missions/events.go
@@ -18,6 +18,13 @@ const (
// mission. The caller supplies quest unlock state; locked and scheduled event
// missions cannot become eligible merely because they share the condition.
func (s *Service) RecordEvent(conditionType, subType, count uint64, unlocked func(uint64, uint64) bool) error {
+ if observer, ok := s.eventHandler.(interface {
+ RecordEvent(uint64, uint64, uint64, func(uint64, uint64) bool) error
+ }); ok {
+ if err := observer.RecordEvent(conditionType, subType, count, unlocked); err != nil {
+ return err
+ }
+ }
s.mu.Lock()
defer s.mu.Unlock()
if err := s.rolloverLocked(); err != nil {
@@ -30,6 +37,13 @@ func (s *Service) RecordEvent(conditionType, subType, count uint64, unlocked fun
// progress map stores the period marker, so reconnects and restarts cannot
// inflate weekly connection missions and no additional save format is needed.
func (s *Service) RecordLogin(unlocked func(uint64, uint64) bool) error {
+ if observer, ok := s.eventHandler.(interface {
+ RecordLogin(func(uint64, uint64) bool) error
+ }); ok {
+ if err := observer.RecordLogin(unlocked); err != nil {
+ return err
+ }
+ }
s.mu.Lock()
defer s.mu.Unlock()
if err := s.rolloverLocked(); err != nil {
@@ -44,6 +58,13 @@ func (s *Service) RecordLogin(unlocked func(uint64, uint64) bool) error {
// CompleteSingleTargetEvent covers evidence of at least one occurrence, without
// inventing a count or subtype for richer missions (e.g. a monster encounter win).
func (s *Service) CompleteSingleTargetEvent(conditionType uint64, unlocked func(uint64, uint64) bool) error {
+ if observer, ok := s.eventHandler.(interface {
+ CompleteSingleTargetEvent(uint64, func(uint64, uint64) bool) error
+ }); ok {
+ if err := observer.CompleteSingleTargetEvent(conditionType, unlocked); err != nil {
+ return err
+ }
+ }
s.mu.Lock()
defer s.mu.Unlock()
if err := s.rolloverLocked(); err != nil {
diff --git a/go/internal/server/missions/missions.go b/go/internal/server/missions/missions.go
index 14035af..b54a780 100644
--- a/go/internal/server/missions/missions.go
+++ b/go/internal/server/missions/missions.go
@@ -46,6 +46,9 @@ func (s *Service) AttachAchievementProgress(source AchievementProgressSource) er
}
type Service struct {
+ eventHandler interface {
+ Handle(string, []byte) (int, []byte, bool, error)
+ }
levelRewards *gamedata.AchievementLevelDesign
levelReward uint64
achievementProgress AchievementProgressSource
@@ -217,6 +220,37 @@ func (s *Service) applyCompletionDependencies(next *snapshot, completed gamedata
}
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
+ if s.eventHandler != nil && path == "/MissionUpdate" {
+ regular, event, err := splitMissionUpdates(request)
+ if err != nil {
+ return 119, nil, true, err
+ }
+ if len(event) > 0 {
+ if len(regular) > 0 {
+ s.mu.Lock()
+ err = s.validateRegularUpdates(regular)
+ s.mu.Unlock()
+ if err != nil {
+ return 119, nil, true, err
+ }
+ }
+ if _, _, handled, err := s.eventHandler.Handle(path, event); err != nil || !handled {
+ if err == nil {
+ err = ErrInvalidRequest
+ }
+ return 119, nil, true, err
+ }
+ if len(regular) == 0 {
+ return 119, nil, true, nil
+ }
+ request = regular
+ }
+ }
+ if s.eventHandler != nil && path == "/MissionClear" {
+ if code, response, handled, err := s.eventHandler.Handle(path, request); handled {
+ return code, response, handled, err
+ }
+ }
s.mu.Lock()
defer s.mu.Unlock()
if err := s.rolloverLocked(); err != nil {
@@ -255,6 +289,84 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
}
}
+// Each update carries its own event identity; a client can acknowledge regular
+// and scheduled missions together in one request.
+func splitMissionUpdates(request []byte) (regular, event []byte, err error) {
+ if err = requireSeq(request); err != nil {
+ return
+ }
+ seq, _, _ := wire.Varint(request, 1)
+ err = wire.Walk(request, func(f wire.Field) error {
+ if f.Number != 2 {
+ return nil
+ }
+ if f.Type != 2 {
+ return ErrInvalidRequest
+ }
+ uid, e := scalar(f.Value, 4)
+ if e != nil {
+ return e
+ }
+ if uid == 0 {
+ if len(regular) == 0 {
+ regular = wire.AppendVarint(nil, 1, seq)
+ }
+ regular = wire.AppendBytes(regular, 2, f.Value)
+ } else {
+ if len(event) == 0 {
+ event = wire.AppendVarint(nil, 1, seq)
+ }
+ event = wire.AppendBytes(event, 2, f.Value)
+ }
+ return nil
+ })
+ return
+}
+
+func (s *Service) validateRegularUpdates(request []byte) error {
+ return wire.Walk(request, func(f wire.Field) error {
+ if f.Number != 2 {
+ return nil
+ }
+ if f.Type != 2 {
+ return ErrInvalidRequest
+ }
+ group, err := scalar(f.Value, 1)
+ if err != nil || group == 0 {
+ return ErrInvalidRequest
+ }
+ id, err := scalar(f.Value, 2)
+ if err != nil || id == 0 {
+ return ErrInvalidRequest
+ }
+ if _, err = scalar(f.Value, 3); err != nil {
+ return ErrInvalidRequest
+ }
+ matches := 0
+ for key := range s.design.Missions {
+ if key.GroupType != 2 && key.GroupID == group && key.ID == id {
+ matches++
+ }
+ }
+ if matches != 1 {
+ return ErrInvalidRequest
+ }
+ return nil
+ })
+}
+
+// AttachEventHandler delegates scheduled event operations before regular
+// mission processing. The event service validates calendar and player state.
+func (s *Service) AttachEventHandler(handler interface {
+ Handle(string, []byte) (int, []byte, bool, error)
+}) error {
+ if handler == nil {
+ return errors.New("missions: nil event handler")
+ }
+ s.eventHandler = handler
+ return nil
+}
+
func dailyPeriod(now time.Time) string { return now.UTC().Format("2006-01-02") }
func weeklyPeriod(now time.Time) string {
diff --git a/go/internal/server/missions/mixed_update_test.go b/go/internal/server/missions/mixed_update_test.go
new file mode 100644
index 0000000..7e8c492
--- /dev/null
+++ b/go/internal/server/missions/mixed_update_test.go
@@ -0,0 +1,36 @@
+package missions
+
+import (
+ "bd2server/internal/server/wire"
+ "testing"
+)
+
+func TestSplitMixedMissionUpdates(t *testing.T) {
+ regular := wire.AppendVarint(nil, 1, 1)
+ regular = wire.AppendVarint(regular, 2, 101)
+ regular = wire.AppendVarint(regular, 3, 2)
+ event := wire.AppendVarint(nil, 1, 9)
+ event = wire.AppendVarint(event, 2, 10)
+ event = wire.AppendVarint(event, 3, 1)
+ event = wire.AppendVarint(event, 4, 77)
+ req := wire.AppendVarint(nil, 1, 40)
+ req = wire.AppendBytes(req, 2, regular)
+ req = wire.AppendBytes(req, 2, event)
+ r, e, err := splitMissionUpdates(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, part := range [][]byte{r, e} {
+ seq, _, _ := wire.Varint(part, 1)
+ if seq != 40 {
+ t.Fatal("lost sequence")
+ }
+ }
+ rr, _, _ := wire.Bytes(r, 2)
+ ee, _, _ := wire.Bytes(e, 2)
+ rid, _, _ := wire.Varint(rr, 2)
+ eid, _, _ := wire.Varint(ee, 4)
+ if rid != 101 || eid != 77 {
+ t.Fatal("mixed update was not split by event identity")
+ }
+}
diff --git a/go/internal/server/monsterhunt/calendar_test.go b/go/internal/server/monsterhunt/calendar_test.go
new file mode 100644
index 0000000..d1e9aa7
--- /dev/null
+++ b/go/internal/server/monsterhunt/calendar_test.go
@@ -0,0 +1,76 @@
+package monsterhunt
+
+import (
+ "testing"
+ "time"
+
+ "bd2server/internal/server/readonly"
+ "bd2server/internal/server/wire"
+)
+
+func TestFutureSeasonDoesNotReplaceActiveOrRewardSeason(t *testing.T) {
+ rows := []season{
+ {ID: 5, Start: 100, End: 200},
+ {ID: 3, Start: 300, End: 400},
+ {ID: 999, Start: 500, End: 600},
+ }
+ for _, tc := range []struct{ now, want uint64 }{
+ {50, 5}, {100, 5}, {199, 5}, {200, 5}, {299, 5},
+ {300, 3}, {400, 3}, {500, 999},
+ } {
+ if got := selectSeason(rows, tc.now); got.ID != tc.want {
+ t.Fatalf("time=%d season=%d want=%d", tc.now, got.ID, tc.want)
+ }
+ }
+}
+
+func TestScheduleProjectsOneRegularPreservesIndependentAndHistory(t *testing.T) {
+ field := func(n int, v uint64) readonly.Field { return readonly.Field{Number: n, Type: 0, Varint: v} }
+ row := func(id uint64, independent bool) readonly.Field {
+ flag := uint64(0)
+ if independent {
+ flag = 1
+ }
+ return readonly.Field{Number: 1, Type: 2, Fields: []readonly.Field{
+ {Number: 1, Type: 2, Fields: []readonly.Field{field(1, id)}}, field(6, flag),
+ }}
+ }
+ s := &Service{now: func() time.Time { return time.UnixMilli(150) },
+ seasons: []season{{ID: 1, Start: 100, End: 200}, {ID: 2, Start: 300, End: 400}, {ID: 3, Start: 100, End: 200, Independent: true}},
+ seed: &readonly.Seed{Responses: map[string]readonly.Response{"/MonsterHuntScheduleInfo": {
+ Fields: []readonly.Field{row(1, false), row(2, false), row(3, true), field(2, 5), {Number: 3, Type: 2, Fields: []readonly.Field{field(1, 77)}}},
+ }}},
+ }
+ for _, now := range []int64{150, 350} {
+ s.now = func() time.Time { return time.UnixMilli(now) }
+ _, response, _, err := s.scheduleInfo(wire.AppendVarint(nil, 1, 1))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var ids []uint64
+ history := 0
+ if err := wire.Walk(response, func(f wire.Field) error {
+ if f.Number == 1 {
+ nested, _, _ := wire.Bytes(f.Value, 1)
+ id, _, _ := wire.Varint(nested, 1)
+ ids = append(ids, id)
+ }
+ if f.Number == 3 {
+ history++
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ want := uint64(1)
+ if now == 350 {
+ want = 2
+ }
+ if len(ids) != 2 || ids[0] != want || ids[1] != 3 || history != 1 {
+ t.Fatalf("time=%d rows=%v history=%d", now, ids, history)
+ }
+ if len(s.seed.Responses["/MonsterHuntScheduleInfo"].Fields) != 5 {
+ t.Fatal("projection mutated published calendars")
+ }
+ }
+}
diff --git a/go/internal/server/monsterhunt/monsterhunt.go b/go/internal/server/monsterhunt/monsterhunt.go
index b904095..d0fd196 100644
--- a/go/internal/server/monsterhunt/monsterhunt.go
+++ b/go/internal/server/monsterhunt/monsterhunt.go
@@ -25,7 +25,10 @@ import (
const BattleMode uint64 = 8
const PracticeMode uint64 = 24
-type season struct{ ID, Hunt, Start, End, Calculate, RankGroup uint64 }
+type season struct {
+ ID, Hunt, Start, End, Calculate, RankGroup uint64
+ Independent bool
+}
type user struct {
Season, Hunt, Level, StartHP, HighestHP, CurrentDamage, DailyDamage, HighestDate, DailyLevel, DailyDate uint64
Played, Claimed bool
@@ -59,6 +62,7 @@ type Service struct {
now func() time.Time
active map[string]encounter
load func(uint64) (*gamedata.MonsterHunt, error)
+ rewardGrant func(string, []gamedata.Reward) ([]byte, error)
}
func Open(storage stateio.Store, root, version string, seed *readonly.Seed, inventory *player.Inventory, wallet *player.Wallet) (*Service, error) {
@@ -99,6 +103,8 @@ func Open(storage stateio.Store, root, version string, seed *readonly.Seed, inve
c.Hunt = v.Varint
case 4:
c.Calculate = v.Varint
+ case 6:
+ c.Independent = v.Varint != 0
case 7:
c.RankGroup = v.Varint
}
@@ -169,6 +175,9 @@ func Open(storage stateio.Store, root, version string, seed *readonly.Seed, inve
}
return s, nil
}
+func (s *Service) AttachRewards(grant func(string, []gamedata.Reward) ([]byte, error)) {
+ s.rewardGrant = grant
+}
func (s *Service) AttachCharacters(c *player.CharacterStore) { s.characters = c }
func (s *Service) AttachPresetRuntime(c *player.CharacterStore, e *player.EquipmentInventory, col *player.CollectionStore) error {
if c == nil || e == nil || col == nil {
@@ -197,17 +206,81 @@ func (s *Service) BeginSession(id string) {
// Encounters are keyed by session-specific receipts and survive repeated activation.
}
func (s *Service) current() season {
- c := s.seasons[0]
- for _, v := range s.seasons {
- if v.ID > c.ID {
- c = v
+ // Future rows are public calendar data, not an instruction to switch the
+ // player's current hunt before its window begins. Regular client schedules
+ // have a single slot; independent hunts use their separate client list.
+ now := uint64(s.now().UnixMilli())
+ var regular []season
+ for _, c := range s.seasons {
+ if !c.Independent {
+ regular = append(regular, c)
}
}
- return c
+ if len(regular) == 0 {
+ regular = s.seasons
+ }
+ return selectSeason(regular, now)
+}
+
+func selectSeason(rows []season, now uint64) season {
+ var active, begun, future season
+ for _, c := range rows {
+ if c.Start <= now {
+ if begun.ID == 0 || c.Start > begun.Start || c.Start == begun.Start && c.ID > begun.ID {
+ begun = c
+ }
+ if now < c.End && (active.ID == 0 || c.Start > active.Start || c.Start == active.Start && c.ID > active.ID) {
+ active = c
+ }
+ } else if future.ID == 0 || c.Start < future.Start || c.Start == future.Start && c.ID < future.ID {
+ future = c
+ }
+ }
+ if active.ID != 0 {
+ return active
+ }
+ if begun.ID != 0 {
+ return begun
+ }
+ return future
+}
+
+func (s *Service) scheduleInfo(req []byte) (int, []byte, bool, error) {
+ current := s.current()
+ response := s.seed.Responses["/MonsterHuntScheduleInfo"]
+ fields := make([]readonly.Field, 0, len(response.Fields))
+ for _, field := range response.Fields {
+ if field.Number == 1 && field.Type == 2 {
+ var id uint64
+ independent := false
+ for _, f := range field.Fields {
+ if f.Number == 1 {
+ for _, v := range f.Fields {
+ if v.Number == 1 {
+ id = v.Varint
+ }
+ }
+ }
+ if f.Number == 6 {
+ independent = f.Varint != 0
+ }
+ }
+ if !independent && id != current.ID {
+ continue
+ }
+ }
+ fields = append(fields, field)
+ }
+ // Keep the entire loaded calendar immutable; projection only affects this
+ // response so subsequent requests can cross a season boundary without reload.
+ projected := &readonly.Seed{Version: s.seed.Version, Responses: map[string]readonly.Response{
+ "/MonsterHuntScheduleInfo": {PacketCode: response.PacketCode, Fields: fields},
+ }}
+ return projected.Handle("/MonsterHuntScheduleInfo", req)
}
func (s *Service) playing(c season) bool {
n := uint64(s.now().UnixMilli())
- return n >= c.Start && n <= c.End
+ return n >= c.Start && n < c.End
}
func (s *Service) getUser(c season) (user, error) {
u, ok := s.state.Users[strconv.FormatUint(c.ID, 10)]
@@ -333,6 +406,9 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [
}
return code, r.Response, true, nil
}
+ if path == "/MonsterHuntScheduleInfo" {
+ return s.scheduleInfo(req)
+ }
c := s.current()
u, e := s.getUser(c)
if e != nil {
@@ -344,8 +420,6 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [
var updateReceipt string
var updateEncounter encounter
switch path {
- case "/MonsterHuntScheduleInfo":
- return s.seed.Handle(path, req)
case "/MonsterHuntUserInfo":
out = wire.AppendBytes(nil, 1, s.encodeUser(u, c))
if u.Played {
diff --git a/go/internal/server/monsterhunt/monsterhunt_test.go b/go/internal/server/monsterhunt/monsterhunt_test.go
index 7439ef5..7031046 100644
--- a/go/internal/server/monsterhunt/monsterhunt_test.go
+++ b/go/internal/server/monsterhunt/monsterhunt_test.go
@@ -1,6 +1,7 @@
package monsterhunt
import (
+ "bd2server/internal/server/calendar"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/readonly"
@@ -23,6 +24,14 @@ func installed(t *testing.T) (*Service, *stateio.Memory) {
if e != nil {
t.Fatal(e)
}
+ calendars, e := calendar.LoadDirectory(filepath.Join("..", "..", "..", "..", "schedules"), "2.35.10", "20260923193640")
+ if e != nil {
+ t.Fatal(e)
+ }
+ seed, e = calendars.ApplyReadonly(seed)
+ if e != nil {
+ t.Fatal(e)
+ }
store := stateio.NewMemory()
inv, e := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
if e != nil {
diff --git a/go/internal/server/monsterhunt/rewards.go b/go/internal/server/monsterhunt/rewards.go
index 794737c..97bbd16 100644
--- a/go/internal/server/monsterhunt/rewards.go
+++ b/go/internal/server/monsterhunt/rewards.go
@@ -56,6 +56,13 @@ func (s *Service) rankRewards(d *gamedata.MonsterHunt, group uint64) []gamedata.
return selected.Rewards
}
func (s *Service) grant(identity string, rewards []gamedata.BattleReward) ([]byte, error) {
+ if s.rewardGrant != nil {
+ rs := make([]gamedata.Reward, len(rewards))
+ for i, r := range rewards {
+ rs[i] = gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}
+ }
+ return s.rewardGrant(identity, rs)
+ }
var currency []gamedata.Reward
var stack []gamedata.BattleReward
for _, r := range rewards {
diff --git a/go/internal/server/player/commerce_items.go b/go/internal/server/player/commerce_items.go
new file mode 100644
index 0000000..4b52131
--- /dev/null
+++ b/go/internal/server/player/commerce_items.go
@@ -0,0 +1,76 @@
+package player
+
+import (
+ "fmt"
+ "math"
+)
+
+// GrantCommerceOnce preserves the exact item expiry supplied by the commerce
+// entitlement policy. It shares the existing atomic inventory layout.
+func (s *Inventory) GrantCommerceOnce(identity string, rewards []Item) ([]Item, error) {
+ if identity == "" {
+ return nil, fmt.Errorf("player: missing commerce reward identity")
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.owned.Granted[identity] {
+ var out []Item
+ for _, idx := range s.owned.GrantItems[identity] {
+ for _, item := range s.owned.Items {
+ if item.InvenIndex == idx {
+ out = append(out, item)
+ }
+ }
+ }
+ return out, nil
+ }
+ next := cloneOwnedSnapshot(s.owned)
+ var out []Item
+ for _, r := range rewards {
+ if r.ID == 0 || (r.Type != 62 && r.Type != 19) || r.Count == 0 || r.Count > math.MaxInt32 || r.Type == 19 && r.ExpiryTime == 0 || next.NextIndex == math.MaxUint64 {
+ return nil, fmt.Errorf("player: invalid commerce item")
+ }
+ if r.Type == 19 {
+ found := false
+ for i, current := range next.Items {
+ if current.Type == 19 && current.ID == r.ID {
+ r.InvenIndex = current.InvenIndex
+ r.Count = 1
+ next.Items[i] = r
+ next.GrantItems[identity] = append(next.GrantItems[identity], r.InvenIndex)
+ out = append(out, r)
+ found = true
+ break
+ }
+ }
+ if found {
+ continue
+ }
+ r.Count = 1
+ }
+ r.InvenIndex = next.NextIndex
+ next.NextIndex++
+ next.Items = append(next.Items, r)
+ next.GrantItems[identity] = append(next.GrantItems[identity], r.InvenIndex)
+ out = append(out, r)
+ }
+ next.Granted[identity] = true
+ if err := s.commitOwned(next); err != nil {
+ return nil, err
+ }
+ s.owned = next
+ return out, nil
+}
+func (s *Inventory) ContentTicketExpiry(id uint64) uint64 {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var expiry uint64
+ for _, items := range [][]Item{s.starter.Items, s.owned.Items} {
+ for _, i := range items {
+ if i.Type == 19 && i.ID == id && i.Count > 0 && i.ExpiryTime > expiry {
+ expiry = i.ExpiryTime
+ }
+ }
+ }
+ return expiry
+}
diff --git a/go/internal/server/player/commerce_items_test.go b/go/internal/server/player/commerce_items_test.go
new file mode 100644
index 0000000..86ee91e
--- /dev/null
+++ b/go/internal/server/player/commerce_items_test.go
@@ -0,0 +1,28 @@
+package player
+
+import (
+ "bd2server/internal/server/stateio"
+ "testing"
+)
+
+func TestCommerceTimedGrantRenewalAndRetry(t *testing.T) {
+ inv, err := OpenInventory(stateio.NewMemory(), &Starter{Version: "2.35.10"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ first, err := inv.GrantCommerceOnce("a", []Item{{Type: 19, ID: 38, Count: 1, ExpiryTime: 1000}})
+ if err != nil || len(first) != 1 {
+ t.Fatalf("%+v %v", first, err)
+ }
+ second, err := inv.GrantCommerceOnce("b", []Item{{Type: 19, ID: 38, Count: 2, ExpiryTime: 2000}})
+ if err != nil || len(second) != 1 || second[0].InvenIndex != first[0].InvenIndex || inv.ContentTicketExpiry(38) != 2000 {
+ t.Fatalf("renewal %+v %v", second, err)
+ }
+ retry, err := inv.GrantCommerceOnce("b", nil)
+ if err != nil || len(retry) != 1 || len(inv.owned.Items) != 1 {
+ t.Fatalf("retry %+v %v", retry, err)
+ }
+ if _, err = inv.GrantCommerceOnce("bad", []Item{{Type: 19, ID: 38, Count: 1}}); err == nil {
+ t.Fatal("untimed ticket accepted")
+ }
+}
diff --git a/go/internal/server/player/wallet_exchange.go b/go/internal/server/player/wallet_exchange.go
new file mode 100644
index 0000000..35b01de
--- /dev/null
+++ b/go/internal/server/player/wallet_exchange.go
@@ -0,0 +1,69 @@
+package player
+
+import (
+ "fmt"
+ "math"
+
+ "bd2server/internal/server/gamedata"
+)
+
+// ExchangeOnce applies a server-calculated cost and reward in one wallet save.
+// The caller's account transaction also owns inventory and gameplay receipts.
+func (s *Wallet) ExchangeOnce(identity string, costs, rewards []gamedata.Reward) error {
+ if identity == "" {
+ return fmt.Errorf("player: missing wallet exchange identity")
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.state.Spent[identity] {
+ return nil
+ }
+ next := cloneWallet(s.state)
+ if err := exchangeCurrency(&next.Currency, costs, rewards); err != nil {
+ return err
+ }
+ next.Spent[identity] = true
+ return s.commit(next, entry("spent", identity, []byte("true"))...)
+}
+
+func (s *Wallet) CanExchange(costs, rewards []gamedata.Reward) error {
+ c := s.Snapshot()
+ return exchangeCurrency(&c, costs, rewards)
+}
+
+func exchangeCurrency(c *Currency, costs, rewards []gamedata.Reward) error {
+ value := func(t uint64) *uint64 {
+ switch t {
+ case 2:
+ return &c.Jewelry
+ case 3:
+ return &c.FreeJewelry
+ case 4:
+ return &c.Gold
+ case 12:
+ return &c.Catalyst
+ case 20:
+ return &c.Mileage
+ case 22:
+ return &c.HopePowder
+ case 68:
+ return &c.EquipMileage
+ }
+ return nil
+ }
+ for _, r := range costs {
+ v := value(r.Type)
+ if v == nil || r.ID != 0 || r.Count == 0 || *v < r.Count {
+ return fmt.Errorf("player: invalid or insufficient currency %d", r.Type)
+ }
+ *v -= r.Count
+ }
+ for _, r := range rewards {
+ v := value(r.Type)
+ if v == nil || r.ID != 0 || r.Count == 0 || *v > math.MaxInt64-r.Count || r.Count > math.MaxInt64 {
+ return fmt.Errorf("player: invalid or overflowing currency %d", r.Type)
+ }
+ *v += r.Count
+ }
+ return nil
+}
diff --git a/go/internal/server/progress/research.go b/go/internal/server/progress/research.go
new file mode 100644
index 0000000..f07ad5c
--- /dev/null
+++ b/go/internal/server/progress/research.go
@@ -0,0 +1,61 @@
+package progress
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+func (s *Store) ResearchObjects(pack int) ([]int, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ store, err := s.fieldRewardEntries()
+ if err != nil {
+ return nil, err
+ }
+ entries, err := store.ListEntries("progress", "field_research")
+ if err != nil {
+ return nil, err
+ }
+ var ids []int
+ prefix := fmt.Sprintf("%d:", pack)
+ for key, raw := range entries {
+ if string(raw) != "once" {
+ return nil, fmt.Errorf("progress: invalid research entry")
+ }
+ if strings.HasPrefix(key, prefix) {
+ id, e := strconv.Atoi(strings.TrimPrefix(key, prefix))
+ if e != nil || id <= 0 {
+ return nil, fmt.Errorf("progress: invalid research identity")
+ }
+ ids = append(ids, id)
+ }
+ }
+ sort.Ints(ids)
+ return ids, nil
+}
+func (s *Store) ResearchObjectReply(pack, id int) ([]byte, bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ store, err := s.fieldRewardEntries()
+ if err != nil {
+ return nil, false, err
+ }
+ return store.LoadEntry("progress", "field_research_rewards", fieldRewardKey(pack, id))
+}
+func (s *Store) MarkResearchObject(pack, id int, rewardItems []byte) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if pack <= 0 || id <= 0 {
+ return fmt.Errorf("progress: invalid research identity")
+ }
+ store, err := s.fieldRewardEntries()
+ if err != nil {
+ return err
+ }
+ if err = store.PutEntry("progress", "field_research_rewards", fieldRewardKey(pack, id), rewardItems); err != nil {
+ return err
+ }
+ return store.PutEntry("progress", "field_research", fieldRewardKey(pack, id), []byte("once"))
+}
diff --git a/go/internal/server/readonly/readonly_test.go b/go/internal/server/readonly/readonly_test.go
index 5ee8a12..3442f2f 100644
--- a/go/internal/server/readonly/readonly_test.go
+++ b/go/internal/server/readonly/readonly_test.go
@@ -47,10 +47,11 @@ func TestSeedProtocolEncodingAndRoundTrip(t *testing.T) {
}
func TestCashProductEventIndexIsUniqueSemanticSeedFact(t *testing.T) {
- seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "readonly.json"))
- if err != nil {
- t.Fatal(err)
- }
+ seed := &Seed{Version: StateVersion(), Responses: map[string]Response{
+ "/CashShopInfo": {PacketCode: 60, Fields: []Field{{Number: 1, Type: 2, Fields: []Field{
+ {Number: 1, Type: 0, Varint: 1100001}, {Number: 2, Type: 0, Varint: 9100033}, {Number: 8, Type: 0, Varint: 1171},
+ }}}},
+ }}
event, err := seed.CashProductEventIndex(1100001, 9100033)
if err != nil || event != 1171 {
t.Fatalf("event=%d err=%v", event, err)
diff --git a/go/internal/server/schedule/schedule.go b/go/internal/server/schedule/schedule.go
index cdd07b2..aa2bcfb 100644
--- a/go/internal/server/schedule/schedule.go
+++ b/go/internal/server/schedule/schedule.go
@@ -4,11 +4,8 @@ package schedule
import (
"bd2server/internal/server/versionconfig"
- "encoding/json"
"errors"
- "fmt"
"math"
- "os"
"bd2server/internal/server/wire"
)
@@ -44,23 +41,6 @@ type Service struct {
Regular []RegularSeason `json:"regular"`
}
-// Load reads this server's versioned calendar policy. It does not infer live
-// official seasons from today's clock or from static reward design tables.
-func Load(path string) (*Service, error) {
- raw, err := os.ReadFile(path)
- if err != nil {
- return nil, fmt.Errorf("schedule: read seed: %w", err)
- }
- var s Service
- if err := json.Unmarshal(raw, &s); err != nil {
- return nil, fmt.Errorf("schedule: decode seed: %w", err)
- }
- if err := s.Validate(); err != nil {
- return nil, err
- }
- return &s, nil
-}
-
func (s *Service) Validate() error {
if s == nil || s.Version != versionconfig.State() || s.CalculateMilliseconds == 0 || s.CalculateMilliseconds > math.MaxInt32 || len(s.Contents) == 0 {
return errors.New("schedule: invalid calendar version/calculation interval")
diff --git a/go/internal/server/schedule/schedule_test.go b/go/internal/server/schedule/schedule_test.go
index 76c91e8..01cf07e 100644
--- a/go/internal/server/schedule/schedule_test.go
+++ b/go/internal/server/schedule/schedule_test.go
@@ -1,15 +1,13 @@
package schedule
import (
- "os"
- "path/filepath"
+ "encoding/json"
"testing"
"bd2server/internal/server/wire"
)
-func TestCalendarReloadsChangedIDsAndTimesFromFile(t *testing.T) {
- path := filepath.Join(t.TempDir(), "schedule.json")
+func TestCalendarUsesInjectedIDsAndTimes(t *testing.T) {
for _, revision := range []struct {
id, start, end uint64
raw string
@@ -17,11 +15,11 @@ func TestCalendarReloadsChangedIDsAndTimesFromFile(t *testing.T) {
{709, 101, 303, `{"version":"2.35.10","calculate_milliseconds":17,"contents":[{"id":81,"current":{"id":709,"start_milliseconds":101,"end_milliseconds":303},"next":{"id":710,"start_milliseconds":401,"end_milliseconds":501}}],"regular":[{"content_id":81,"season":9}]}`},
{811, 701, 903, `{"version":"2.35.10","calculate_milliseconds":17,"contents":[{"id":81,"current":{"id":811,"start_milliseconds":701,"end_milliseconds":903},"next":{"id":812,"start_milliseconds":1001,"end_milliseconds":1201}}],"regular":[{"content_id":81,"season":11}]}`},
} {
- if err := os.WriteFile(path, []byte(revision.raw), 0600); err != nil {
+ var service Service
+ if err := json.Unmarshal([]byte(revision.raw), &service); err != nil {
t.Fatal(err)
}
- service, err := Load(path)
- if err != nil {
+ if err := service.Validate(); err != nil {
t.Fatal(err)
}
_, response, _, err := service.Handle("/ScheduleInfo", wire.AppendVarint(nil, 1, 1))
@@ -37,40 +35,6 @@ func TestCalendarReloadsChangedIDsAndTimesFromFile(t *testing.T) {
t.Fatalf("season=%d times=%d/%d", id, start, end)
}
}
- if err := os.WriteFile(path, []byte(`{"version":"2.35.10","calculate_milliseconds":1,"contents":[{"id":1,"current":{"id":2,"start_milliseconds":4,"end_milliseconds":3}}]}`), 0600); err != nil {
- t.Fatal(err)
- }
- if _, err := Load(path); err == nil {
- t.Fatal("reversed date accepted")
- }
-}
-
-func TestVersion23510ContainsRequiredContentSix(t *testing.T) {
- service, err := Load("../../../seed/v2_35_10/schedule.json")
- if err != nil {
- t.Fatal(err)
- }
- code, response, handled, err := service.Handle("/ScheduleInfo", wire.AppendVarint(nil, 1, 1))
- if err != nil || !handled || code != 117 {
- t.Fatalf("schedule code=%d handled=%v err=%v", code, handled, err)
- }
- found := false
- if err := wire.Walk(response, func(field wire.Field) error {
- if field.Number == 2 {
- id, _, _ := wire.Varint(field.Value, 1)
- if id == 6 {
- found = true
- current, ok, _ := wire.Bytes(field.Value, 2)
- season, _, _ := wire.Varint(current, 1)
- if !ok || season != 26 {
- t.Fatalf("content 6 current season=%d found=%v", season, ok)
- }
- }
- }
- return nil
- }); err != nil || !found {
- t.Fatalf("content 6 missing err=%v", err)
- }
}
func TestScheduleRejectsMissingSequence(t *testing.T) {
diff --git a/go/internal/server/transport/commerce.go b/go/internal/server/transport/commerce.go
new file mode 100644
index 0000000..d276627
--- /dev/null
+++ b/go/internal/server/transport/commerce.go
@@ -0,0 +1,23 @@
+package transport
+
+import (
+ "encoding/json"
+ "net/http"
+)
+
+func (h HTTP) clientCommerce(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ w.Header().Set("Allow", http.MethodGet)
+ http.Error(w, "GET required", http.StatusMethodNotAllowed)
+ return
+ }
+ if h.CommerceManifest == nil {
+ http.Error(w, "server purchases are unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ if err := json.NewEncoder(w).Encode(h.CommerceManifest()); err != nil {
+ h.logger().Error("encode commerce manifest", "error", err)
+ }
+}
diff --git a/go/internal/server/transport/commerce_test.go b/go/internal/server/transport/commerce_test.go
new file mode 100644
index 0000000..957acef
--- /dev/null
+++ b/go/internal/server/transport/commerce_test.go
@@ -0,0 +1,32 @@
+package transport
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestCommerceManifestIsReadOnlyAndUnavailableWithoutPolicy(t *testing.T) {
+ without := httptest.NewRecorder()
+ HTTP{}.Handler().ServeHTTP(without, httptest.NewRequest(http.MethodGet, "/client/commerce", nil))
+ if without.Code != http.StatusServiceUnavailable {
+ t.Fatalf("missing policy status=%d", without.Code)
+ }
+ calls := 0
+ h := HTTP{CommerceManifest: func() any {
+ calls++
+ return map[string]any{"schema_version": 1, "products": []any{map[string]any{"sku": "test", "item_type": 2, "amount": 1000}}}
+ }}.Handler()
+ post := httptest.NewRecorder()
+ h.ServeHTTP(post, httptest.NewRequest(http.MethodPost, "/client/commerce", strings.NewReader(`{"amount":0}`)))
+ if post.Code != http.StatusMethodNotAllowed || calls != 0 {
+ t.Fatalf("policy accepted mutation: status=%d calls=%d", post.Code, calls)
+ }
+ get := httptest.NewRecorder()
+ h.ServeHTTP(get, httptest.NewRequest(http.MethodGet, "/client/commerce", nil))
+ if get.Code != http.StatusOK || calls != 1 || get.Header().Get("Cache-Control") != "no-store" ||
+ !strings.Contains(get.Body.String(), `"amount":1000`) {
+ t.Fatalf("manifest status=%d calls=%d headers=%v body=%s", get.Code, calls, get.Header(), get.Body.String())
+ }
+}
diff --git a/go/internal/server/transport/http.go b/go/internal/server/transport/http.go
index 72f0a8e..2fa954a 100644
--- a/go/internal/server/transport/http.go
+++ b/go/internal/server/transport/http.go
@@ -109,6 +109,7 @@ type HTTP struct {
ResourcePolicy resourcepolicy.Public
Availability Availability
InstanceID string
+ CommerceManifest func() any
}
func (h HTTP) Handler() http.Handler {
@@ -118,6 +119,7 @@ func (h HTTP) Handler() http.Handler {
mux.HandleFunc("/auth/config", h.authenticationConfig)
mux.HandleFunc("/client/resources", h.clientResources)
mux.HandleFunc("/client/runtime", h.clientRuntime)
+ mux.HandleFunc("/client/commerce", h.clientCommerce)
if h.AuthenticationHandler != nil {
mux.Handle("/auth/", h.withAvailability(h.AuthenticationHandler))
}
diff --git a/go/internal/server/versionconfig/config.go b/go/internal/server/versionconfig/config.go
index ea17c44..21816b0 100644
--- a/go/internal/server/versionconfig/config.go
+++ b/go/internal/server/versionconfig/config.go
@@ -41,6 +41,7 @@ type PluginVersions struct {
LocalIdentity string `json:"local_identity"`
CaptureEnvironment string `json:"capture_environment"`
LoginUI string `json:"login_ui"`
+ CashShop string `json:"cash_shop"`
}
// Load reads one explicit version file. Unknown fields and trailing JSON are
diff --git a/go/internal/server/world/cash_packages.go b/go/internal/server/world/cash_packages.go
new file mode 100644
index 0000000..4614e8b
--- /dev/null
+++ b/go/internal/server/world/cash_packages.go
@@ -0,0 +1,19 @@
+package world
+
+// CashPackagePackCleared verifies the complete main quest chain at the
+// requested difficulty using persisted server progress.
+func (s *Service) CashPackagePackCleared(packID, difficulty uint64) bool {
+ if s.storyCatalog == nil || difficulty > 2 || packID == 0 {
+ return false
+ }
+ pack, ok := s.storyCatalog.Packs[int(packID)]
+ if !ok || len(pack.MainQuestIDs) == 0 {
+ return false
+ }
+ for _, quest := range pack.MainQuestIDs {
+ if !s.state.QuestCleared(quest, int(packID), int(difficulty)) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/go/internal/server/world/hunting.go b/go/internal/server/world/hunting.go
new file mode 100644
index 0000000..c281e84
--- /dev/null
+++ b/go/internal/server/world/hunting.go
@@ -0,0 +1,35 @@
+package world
+
+import "fmt"
+
+func (s *Service) AttachHuntingGround(provider interface{ EnsureForPack(int) ([]byte, error) }) error {
+ if provider == nil {
+ return fmt.Errorf("world: missing hunting ground provider")
+ }
+ s.huntingGround = provider
+ return nil
+}
+
+// HuntingEligibility follows the hunting tutorial: hard/extreme grounds need
+// completion of the same story difficulty, independently of the last hunt boss.
+func (s *Service) HuntingEligibility(pack int, difficulty uint64) error {
+ if difficulty > 2 || !s.packUnlocked(pack) {
+ return fmt.Errorf("world: hunting pack or difficulty locked")
+ }
+ if difficulty == 0 {
+ return nil
+ }
+ if s.storyCatalog == nil {
+ return fmt.Errorf("world: hunting story design unavailable")
+ }
+ d, ok := s.storyCatalog.Packs[pack]
+ if !ok || len(d.MainQuestIDs) == 0 || !s.questDifficulties[pack][int(difficulty)] {
+ return fmt.Errorf("world: hunting difficulty unavailable")
+ }
+ for _, id := range d.MainQuestIDs {
+ if !s.state.QuestCleared(id, pack, int(difficulty)) {
+ return fmt.Errorf("world: hunting requires completed story difficulty")
+ }
+ }
+ return nil
+}
diff --git a/go/internal/server/world/hunting_test.go b/go/internal/server/world/hunting_test.go
new file mode 100644
index 0000000..56a5fae
--- /dev/null
+++ b/go/internal/server/world/hunting_test.go
@@ -0,0 +1,39 @@
+package world
+
+import (
+ "testing"
+
+ "bd2server/internal/server/wire"
+)
+
+type fakeHuntingGround struct {
+ calls []int
+ row []byte
+}
+
+func (h *fakeHuntingGround) EnsureForPack(pack int) ([]byte, error) {
+ h.calls = append(h.calls, pack)
+ return h.row, nil
+}
+
+func TestPackLoadRestoresActualHuntingState(t *testing.T) {
+ s := testService()
+ h := &fakeHuntingGround{row: wire.AppendVarint(nil, 2, 91)}
+ if err := s.AttachHuntingGround(h); err != nil {
+ t.Fatal(err)
+ }
+ out, err := s.packInfoFor(s.seed.PackID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, ok, err := wire.Bytes(out, 12)
+ if err != nil || !ok {
+ t.Fatalf("ground missing: %v", err)
+ }
+ if id, _, _ := wire.Varint(b, 2); id != 91 {
+ t.Fatalf("seed placeholder replaced state: %d", id)
+ }
+ if len(h.calls) != 1 || h.calls[0] != s.seed.PackID {
+ t.Fatal("wrong loaded pack")
+ }
+}
diff --git a/go/internal/server/world/pack_reward_counts.go b/go/internal/server/world/pack_reward_counts.go
new file mode 100644
index 0000000..8ccb360
--- /dev/null
+++ b/go/internal/server/world/pack_reward_counts.go
@@ -0,0 +1,262 @@
+package world
+
+import (
+ "bd2server/internal/server/gamedata"
+ "bd2server/internal/server/wire"
+ "encoding/binary"
+ "fmt"
+)
+
+func (s *Service) AttachResearchRuntime(root, version string, economy interface {
+ Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
+}) error {
+ if economy == nil {
+ return fmt.Errorf("world: nil research economy")
+ }
+ chars, err := gamedata.LoadResearchCharacters(root, version)
+ if err != nil {
+ return err
+ }
+ s.researchCharacters = chars
+ s.researchEconomy = economy
+ s.researchDesigns = map[int]gamedata.FieldResearchDesign{}
+ s.researchLoader = func(pack int) (gamedata.FieldResearchDesign, error) {
+ return gamedata.LoadFieldResearch(root, version, pack)
+ }
+ return nil
+}
+func (s *Service) researchDesign(pack int) (gamedata.FieldResearchDesign, error) {
+ if d, ok := s.researchDesigns[pack]; ok {
+ return d, nil
+ }
+ if s.researchLoader == nil {
+ return gamedata.FieldResearchDesign{}, fmt.Errorf("world: research design unavailable")
+ }
+ d, err := s.researchLoader(pack)
+ if err != nil {
+ return d, err
+ }
+ s.researchDesigns[pack] = d
+ return d, nil
+}
+func intsRequest(raw []byte, number int) ([]uint64, error) {
+ var out []uint64
+ err := wire.Walk(raw, func(f wire.Field) error {
+ if f.Number != number {
+ return nil
+ }
+ if f.Type == 0 {
+ v, n := binary.Uvarint(f.Value)
+ if n <= 0 {
+ return wire.ErrMalformed
+ }
+ out = append(out, v)
+ return nil
+ }
+ if f.Type != 2 {
+ return ErrInvalidRequest
+ }
+ for raw := f.Value; len(raw) > 0; {
+ v, n := binary.Uvarint(raw)
+ if n <= 0 {
+ return wire.ErrMalformed
+ }
+ out = append(out, v)
+ raw = raw[n:]
+ }
+ return nil
+ })
+ return out, err
+}
+func (s *Service) handleFieldResearch(request []byte) (int, []byte, bool, error) {
+ seq, present, err := wire.Varint(request, 1)
+ if err != nil || !present || seq == 0 {
+ return 59, nil, true, ErrInvalidRequest
+ }
+ pack, err := requestPack(request)
+ if err != nil {
+ return 59, nil, true, err
+ }
+ id, _, err := wire.Varint(request, 3)
+ if err != nil || id == 0 || !s.packUnlocked(pack) || s.state.ActivePackID() != pack {
+ return 59, nil, true, ErrInvalidRequest
+ }
+ design, err := s.researchDesign(pack)
+ if err != nil {
+ return 59, nil, true, err
+ }
+ obj, ok := design.Objects[int(id)]
+ if !ok || obj.CollectionID == 0 && obj.Reward.Type == 0 {
+ return 59, nil, true, ErrInvalidRequest
+ }
+ position, saved := s.state.Position()
+ mapOK := false
+ for _, mapID := range obj.Maps {
+ if saved && position.PackID == pack && position.Position.MapID == mapID {
+ mapOK = true
+ }
+ }
+ if !mapOK {
+ return 59, nil, true, fmt.Errorf("%w: research outside current map", ErrInvalidRequest)
+ }
+ // Quest interactions use QuestUpdate, while FieldObjectResearch grants the
+ // object's collection/reward after its quest interaction is no longer active.
+ for _, quest := range obj.InteractionQuests {
+ if _, active := s.state.QuestInPack(quest, pack, s.questDifficulty(pack)); active && !s.state.QuestCleared(quest, pack, s.questDifficulty(pack)) {
+ return 59, nil, true, fmt.Errorf("%w: research belongs to active quest", ErrInvalidRequest)
+ }
+ }
+ if obj.Type == 1 {
+ eligible := false
+ // This server validates a learned research talent. Client animation and
+ // temporary highlight flags are presentation state rather than authority.
+ if s.characters != nil {
+ for _, c := range s.characters.All() {
+ if c.TalentLevel > 0 && s.researchCharacters[c.ID] {
+ eligible = true
+ break
+ }
+ }
+ }
+ if !eligible {
+ return 59, nil, true, fmt.Errorf("%w: research talent unavailable", ErrInvalidRequest)
+ }
+ }
+ prior, err := s.state.ResearchObjects(pack)
+ if err != nil {
+ return 59, nil, true, err
+ }
+ for _, v := range prior {
+ if v == int(id) {
+ items, found, e := s.state.ResearchObjectReply(pack, int(id))
+ if e != nil {
+ return 59, nil, true, e
+ }
+ if !found {
+ return 59, nil, true, fmt.Errorf("world: researched object reward receipt missing")
+ }
+ return 59, append(wire.AppendVarint(nil, 1, seq), items...), true, nil
+ }
+ }
+ if s.researchEconomy == nil {
+ return 59, nil, true, fmt.Errorf("world: research economy unavailable")
+ }
+ var rewards []gamedata.Reward
+ if obj.Reward.Type != 0 && obj.Reward.Count > 0 {
+ rewards = append(rewards, obj.Reward)
+ }
+ if obj.CollectionID > 0 {
+ rewards = append(rewards, gamedata.Reward{Type: 17, ID: uint64(obj.CollectionID), Count: 1})
+ }
+ bundle, err := s.researchEconomy.Apply(fmt.Sprintf("research:%d:%d", pack, id), nil, rewards)
+ if err != nil {
+ return 59, nil, true, err
+ }
+ var items []byte
+ err = wire.Walk(bundle, func(f wire.Field) error {
+ if f.Number == 1 && f.Type == 2 {
+ items = wire.AppendBytes(items, 2, f.Value)
+ }
+ return nil
+ })
+ if err != nil {
+ return 59, nil, true, err
+ }
+ if err = s.state.MarkResearchObject(pack, int(id), items); err != nil {
+ return 59, nil, true, err
+ }
+ return 59, append(wire.AppendVarint(nil, 1, seq), items...), true, nil
+}
+func fieldCountType(obj gamedata.FieldRewardObject) uint64 {
+ if obj.Type == 3 {
+ return 2
+ }
+ if obj.Type == 6 {
+ return 4
+ }
+ return 3
+}
+func (s *Service) handlePackRewardCounts(request []byte) (int, []byte, bool, error) {
+ seq, present, err := wire.Varint(request, 1)
+ if err != nil || !present || seq == 0 {
+ return 226, nil, true, ErrInvalidRequest
+ }
+ packs, err := intsRequest(request, 2)
+ if err != nil || len(packs) == 0 {
+ return 226, nil, true, ErrInvalidRequest
+ }
+ types, err := intsRequest(request, 3)
+ if err != nil {
+ return 226, nil, true, err
+ }
+ if len(types) == 0 {
+ types = []uint64{1, 2, 3, 4}
+ }
+ var out []byte
+ seen := map[[2]uint64]bool{}
+ for _, p := range packs {
+ pack := int(p)
+ if pack <= 0 || !s.packUnlocked(pack) {
+ return 226, nil, true, ErrInvalidRequest
+ }
+ for _, t := range types {
+ if t < 1 || t > 4 {
+ return 226, nil, true, ErrInvalidRequest
+ }
+ key := [2]uint64{p, t}
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ var count, max uint64
+ if t == 1 {
+ d, e := s.researchDesign(pack)
+ if e != nil {
+ return 226, nil, true, e
+ }
+ ids, e := s.state.ResearchObjects(pack)
+ if e != nil {
+ return 226, nil, true, e
+ }
+ for _, o := range d.Objects {
+ if o.CollectionID > 0 || o.Reward.Type > 0 {
+ max++
+ }
+ }
+ for _, id := range ids {
+ if o, ok := d.Objects[id]; ok && (o.CollectionID > 0 || o.Reward.Type > 0) {
+ count++
+ }
+ }
+ } else {
+ d, e := s.fieldObjectDesign(pack)
+ if e != nil {
+ return 226, nil, true, e
+ }
+ for _, o := range d.Objects {
+ if fieldCountType(o) != t {
+ continue
+ }
+ max++
+ period, e := s.fieldObjectPeriod(o)
+ if e != nil {
+ continue
+ }
+ opened, e := s.state.FieldRewardOpened(pack, o.ID, period)
+ if e != nil {
+ return 226, nil, true, e
+ }
+ if opened {
+ count++
+ }
+ }
+ }
+ info := wire.AppendVarint(nil, 1, t)
+ info = wire.AppendVarint(info, 2, p)
+ info = wire.AppendVarint(info, 3, count)
+ info = wire.AppendVarint(info, 4, max)
+ out = wire.AppendBytes(out, 1, info)
+ }
+ }
+ return 226, out, true, nil
+}
diff --git a/go/internal/server/world/pack_reward_counts_test.go b/go/internal/server/world/pack_reward_counts_test.go
new file mode 100644
index 0000000..ee07281
--- /dev/null
+++ b/go/internal/server/world/pack_reward_counts_test.go
@@ -0,0 +1,83 @@
+package world
+
+import (
+ "bd2server/internal/server/gamedata"
+ "bd2server/internal/server/progress"
+ "bd2server/internal/server/stateio"
+ "bd2server/internal/server/wire"
+ "bytes"
+ "testing"
+)
+
+type researchEconomySpy struct {
+ calls int
+ rewards []gamedata.Reward
+}
+
+func (e *researchEconomySpy) Apply(_ string, _ []gamedata.Reward, rewards []gamedata.Reward) ([]byte, error) {
+ e.calls++
+ e.rewards = rewards
+ item := wire.AppendVarint(nil, 1, 17)
+ item = wire.AppendVarint(item, 2, 103)
+ item = wire.AppendVarint(item, 3, 1)
+ return wire.AppendBytes(nil, 1, item), nil
+}
+func TestResearchRequiresCurrentMapPersistsRewardAndCounts(t *testing.T) {
+ s := testService()
+ storage := stateio.NewMemory()
+ var err error
+ s.state, err = progress.OpenStore(storage)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = s.state.SetActivePackID(21); err != nil {
+ t.Fatal(err)
+ }
+ save := wire.AppendVarint(nil, 1, 1)
+ save = wire.AppendVarint(save, 2, 21)
+ save = wire.AppendString(save, 3, `{"MapId":211,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":null}`)
+ if err = s.state.SaveUserPosition(save); err != nil {
+ t.Fatal(err)
+ }
+ s.researchDesigns = map[int]gamedata.FieldResearchDesign{21: {Objects: map[int]gamedata.FieldResearchObject{401: {ID: 401, Maps: []int{211}, CollectionID: 103}, 402: {ID: 402, Maps: []int{212}, CollectionID: 104}}}}
+ spy := &researchEconomySpy{}
+ s.researchEconomy = spy
+ req := wire.AppendVarint(nil, 1, 10)
+ req = wire.AppendVarint(req, 2, 21)
+ bad := wire.AppendVarint(append([]byte(nil), req...), 3, 402)
+ if _, _, _, err = s.Handle("/FieldObjectResearch", bad); err == nil {
+ t.Fatal("accepted different map")
+ }
+ req = wire.AppendVarint(req, 3, 401)
+ code, first, handled, err := s.Handle("/FieldObjectResearch", req)
+ if err != nil || !handled || code != 59 {
+ t.Fatalf("research: %d %v %v", code, handled, err)
+ }
+ if len(spy.rewards) != 1 || spy.rewards[0].Type != 17 || spy.rewards[0].ID != 103 {
+ t.Fatalf("collection reward must use type17: %+v", spy.rewards)
+ }
+ s.state, err = progress.OpenStore(storage)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, again, _, err := s.Handle("/FieldObjectResearch", req)
+ if err != nil || !bytes.Equal(first, again) || spy.calls != 1 {
+ t.Fatalf("replay duplicated reward or lost response: calls%d err%v", spy.calls, err)
+ }
+ counts := wire.AppendVarint(nil, 1, 11)
+ counts = wire.AppendVarint(counts, 2, 21)
+ counts = wire.AppendVarint(counts, 3, 1)
+ code, body, _, err := s.Handle("/PackRewardObjectCount", counts)
+ if err != nil || code != 226 {
+ t.Fatal(err)
+ }
+ info, _, err := wire.Bytes(body, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ count, _, _ := wire.Varint(info, 3)
+ max, _, _ := wire.Varint(info, 4)
+ if count != 1 || max != 2 {
+ t.Fatalf("count%d max%d", count, max)
+ }
+}
diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go
index f459ea5..494cb6f 100644
--- a/go/internal/server/world/world.go
+++ b/go/internal/server/world/world.go
@@ -169,6 +169,7 @@ func (s *Service) setCurrentPack(packID int) {
}
type Service struct {
+ huntingGround interface{ EnsureForPack(int) ([]byte, error) }
battleActive func() bool
questDifficulties map[int]map[int]bool
startingPackID int
@@ -183,22 +184,28 @@ type Service struct {
fieldObjects map[int]gamedata.FieldObjectDesign
fieldObjectLoader func(int) (gamedata.FieldObjectDesign, error)
fieldReset gamedata.FieldResetSchedule
- squadLevel func() (uint64, error)
- seed Seed
- state *progress.Store
- starter *player.Starter
- equipment *player.EquipmentInventory
- inventory *player.Inventory
- wallet *player.Wallet
- characters *player.CharacterStore
- collection *player.CollectionStore
- decks *deck.Store
- quests map[int]gamedata.QuestDesign
- transition gamedata.PackTransition
- packs map[int]map[int]gamedata.QuestDesign
- transitions map[int]gamedata.PackTransition
- activePackMu sync.RWMutex
- activePack int
+ researchDesigns map[int]gamedata.FieldResearchDesign
+ researchLoader func(int) (gamedata.FieldResearchDesign, error)
+ researchCharacters map[uint64]bool
+ researchEconomy interface {
+ Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
+ }
+ squadLevel func() (uint64, error)
+ seed Seed
+ state *progress.Store
+ starter *player.Starter
+ equipment *player.EquipmentInventory
+ inventory *player.Inventory
+ wallet *player.Wallet
+ characters *player.CharacterStore
+ collection *player.CollectionStore
+ decks *deck.Store
+ quests map[int]gamedata.QuestDesign
+ transition gamedata.PackTransition
+ packs map[int]map[int]gamedata.QuestDesign
+ transitions map[int]gamedata.PackTransition
+ activePackMu sync.RWMutex
+ activePack int
}
func (s *Service) AttachCollection(collection *player.CollectionStore) error {
@@ -225,6 +232,10 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
return s.handleFieldObjectInfo(request)
case "/FieldObjectReward":
return s.handleFieldObjectReward(request)
+ case "/FieldObjectResearch":
+ return s.handleFieldResearch(request)
+ case "/PackRewardObjectCount":
+ return s.handlePackRewardCounts(request)
case "/QuestInfo", "/QuestAccept", "/QuestGiveUp":
return s.handleQuestSelection(path, request)
case "/PackBuy":
@@ -562,6 +573,22 @@ func (s *Service) packInfo() ([]byte, error) {
}
func (s *Service) packInfoFor(packID int) ([]byte, error) {
+ out, err := s.basePackInfoFor(packID)
+ if err != nil || s.huntingGround == nil {
+ return out, err
+ }
+ ground, err := s.huntingGround.EnsureForPack(packID)
+ if err != nil {
+ return nil, err
+ }
+ if len(ground) == 0 {
+ return out, nil
+ }
+ out, _, err = wire.ReplaceBytes(out, 12, ground)
+ return out, err
+}
+
+func (s *Service) basePackInfoFor(packID int) ([]byte, error) {
var out []byte
if active := s.firstUnclearedQuestFor(packID); active != 0 {
quest := s.questInfoWire(packID, active)
diff --git a/go/seed/v2_35_10/gacha_schedule.json b/go/seed/v2_35_10/gacha_schedule.json
deleted file mode 100644
index 19ddbea..0000000
--- a/go/seed/v2_35_10/gacha_schedule.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "client_version": "2.35.10",
- "schedules": [
- { "group_id": 166, "start_time": 1790121600000, "end_time": 1791417599000 },
- { "group_id": 30010, "start_time": 1788998400000, "end_time": 1791417599000 },
- { "group_id": 30011, "start_time": 1788998400000, "end_time": 1791417599000 },
- { "group_id": 206, "start_time": 1788998400000, "end_time": 1791417599000 },
- { "group_id": 205, "start_time": 1788998400000, "end_time": 1791417599000 },
- { "group_id": 208, "start_time": 1790121600000, "end_time": 1791417599000 },
- { "group_id": 153, "start_time": 1790121600000, "end_time": 1791417599000 },
- { "group_id": 72, "start_time": 1790121600000, "end_time": 1791417599000 },
- { "group_id": 71, "start_time": 1790121600000, "end_time": 1791417599000 },
- { "group_id": 207, "start_time": 1790121600000, "end_time": 1791417599000 },
- { "group_id": 1009, "start_time": 1789776000000, "end_time": 1790985600000 }
- ],
- "step_ups": [
- { "group_id": 29, "start_time": 1788998400000, "end_time": 1791417599000 },
- { "group_id": 30, "start_time": 1790121600000, "end_time": 1791417599000 }
- ]
-}
diff --git a/go/seed/v2_35_10/readonly.json b/go/seed/v2_35_10/readonly.json
index 48f108a..4867f3a 100644
--- a/go/seed/v2_35_10/readonly.json
+++ b/go/seed/v2_35_10/readonly.json
@@ -1,1318 +1,6 @@
{
"version": "2.35.10",
"responses": {
- "/CashShopInfo": {
- "packet_code": 60,
- "fields": [
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 76
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1714003200000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 4102444800000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 3
- },
- {
- "number": 8,
- "type": 0,
- "varint": 164
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 77
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1714003200000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 4102444800000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 5
- },
- {
- "number": 8,
- "type": 0,
- "varint": 165
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 160
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1734393600000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 7289621999000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 5
- },
- {
- "number": 8,
- "type": 0,
- "varint": 354
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 159
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1734393600000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 7289621999000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 5
- },
- {
- "number": 8,
- "type": 0,
- "varint": 355
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 158
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1734393600000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 7289621999000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 5
- },
- {
- "number": 8,
- "type": 0,
- "varint": 356
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 293
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1757548800000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 7289621999000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 5
- },
- {
- "number": 8,
- "type": 0,
- "varint": 618
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 292
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1757548800000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 7289621999000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 5
- },
- {
- "number": 8,
- "type": 0,
- "varint": 619
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 291
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1757548800000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 7289621999000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 5
- },
- {
- "number": 8,
- "type": 0,
- "varint": 620
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 302
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1758758400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 7289621999000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 5
- },
- {
- "number": 8,
- "type": 0,
- "varint": 649
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 301
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1758758400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 7289621999000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 5
- },
- {
- "number": 8,
- "type": 0,
- "varint": 650
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 419
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1774483200000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 253377331199000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 5
- },
- {
- "number": 8,
- "type": 0,
- "varint": 930
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 418
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1774483200000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 253374911999000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 5
- },
- {
- "number": 8,
- "type": 0,
- "varint": 931
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 431
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1774483200000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 253378108799000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 937
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 445
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1778112000000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 3482351999000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 997
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 446
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1778112000000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 3483475199000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 998
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 447
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1778112000000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 3483475199000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 999
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 503
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1787788800000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1790121599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1145
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 600001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 75
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1787788800000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1790121599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1154
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 1200001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 31
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1787788800000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1790121599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1155
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 507
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1791417599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1156
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 508
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1791417599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1157
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 509
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1791417599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1158
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 510
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1791417599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1159
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 511
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1791417599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1160
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 512
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1791417599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1161
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 513
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1791417599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1162
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 514
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1791417599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1163
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 515
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1790121599000
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1164
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 516
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1790121599000
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1165
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 517
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1790121599000
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1166
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 500001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 357
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1790121599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1167
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 500001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 358
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1790121599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1168
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 500001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 359
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1790121599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1169
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 500001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 360
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1790121599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1170
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 1100001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 9100033
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1791417599000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 5
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1171
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 1500001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 9100037
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1791417599000
- },
- {
- "number": 6,
- "type": 0
- },
- {
- "number": 8,
- "type": 0,
- "varint": 1172
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 310001
- },
- {
- "number": 2,
- "type": 0,
- "varint": 233
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1789835146000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1790094346000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 10
- }
- ]
- },
- {
- "number": 2,
- "type": 0,
- "varint": 1789862400000
- },
- {
- "number": 3,
- "type": 0,
- "varint": 1789948800000
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1790812800000
- }
- ]
- },
"/ColosseumUserInfo": {
"packet_code": 473,
"fields": [
@@ -1375,325 +63,6 @@
}
]
},
- "/EventHubInfo": {
- "packet_code": 0,
- "fields": [
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 74
- },
- {
- "number": 2,
- "type": 0,
- "varint": 57
- },
- {
- "number": 3,
- "type": 0,
- "varint": 1787788800000
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1788998399000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1790121599000
- },
- {
- "number": 6,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 1
- },
- {
- "number": 2,
- "type": 0,
- "varint": 2
- },
- {
- "number": 3,
- "type": 2,
- "bytes": "9ww="
- }
- ]
- },
- {
- "number": 6,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 2
- },
- {
- "number": 2,
- "type": 0,
- "varint": 2
- },
- {
- "number": 3,
- "type": 2,
- "bytes": "+Aw="
- }
- ]
- },
- {
- "number": 6,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 4
- },
- {
- "number": 2,
- "type": 0,
- "varint": 3
- },
- {
- "number": 3,
- "type": 2,
- "bytes": "TQ=="
- }
- ]
- },
- {
- "number": 6,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 6
- },
- {
- "number": 2,
- "type": 0,
- "varint": 9
- },
- {
- "number": 3,
- "type": 2,
- "bytes": "+Qw="
- }
- ]
- },
- {
- "number": 6,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 7
- },
- {
- "number": 2,
- "type": 0,
- "varint": 8
- },
- {
- "number": 3,
- "type": 2,
- "bytes": "7Qw="
- }
- ]
- },
- {
- "number": 6,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 12
- },
- {
- "number": 2,
- "type": 0,
- "varint": 15
- },
- {
- "number": 3,
- "type": 2,
- "bytes": "+gz7DPwM/Qz+DP8M"
- }
- ]
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 75
- },
- {
- "number": 2,
- "type": 0,
- "varint": 58
- },
- {
- "number": 3,
- "type": 0,
- "varint": 1788998400000
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1790121599000
- },
- {
- "number": 5,
- "type": 0,
- "varint": 1791417599000
- },
- {
- "number": 6,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 1
- },
- {
- "number": 2,
- "type": 0,
- "varint": 2
- },
- {
- "number": 3,
- "type": 2,
- "bytes": "jg0="
- }
- ]
- },
- {
- "number": 6,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 2
- },
- {
- "number": 2,
- "type": 0,
- "varint": 2
- },
- {
- "number": 3,
- "type": 2,
- "bytes": "jw0="
- }
- ]
- },
- {
- "number": 6,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 4
- },
- {
- "number": 2,
- "type": 0,
- "varint": 3
- },
- {
- "number": 3,
- "type": 2,
- "bytes": "Tg=="
- }
- ]
- },
- {
- "number": 6,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 6
- },
- {
- "number": 2,
- "type": 0,
- "varint": 9
- },
- {
- "number": 3,
- "type": 2,
- "bytes": "kA0="
- }
- ]
- },
- {
- "number": 6,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 7
- },
- {
- "number": 2,
- "type": 0,
- "varint": 8
- },
- {
- "number": 3,
- "type": 2,
- "bytes": "hg0="
- }
- ]
- },
- {
- "number": 6,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 8
- },
- {
- "number": 2,
- "type": 0,
- "varint": 10
- },
- {
- "number": 3,
- "type": 2,
- "bytes": "jQ0="
- }
- ]
- }
- ]
- }
- ]
- },
"/FishingInfo": {
"packet_code": 456,
"fields": [
@@ -1813,1422 +182,6 @@
}
]
},
- "/MiniGameHubInfo": {
- "packet_code": 390,
- "fields": [
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 2,
- "type": 0,
- "varint": 705
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 1
- },
- {
- "number": 2,
- "type": 0,
- "varint": 788
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 2
- },
- {
- "number": 2,
- "type": 0,
- "varint": 870
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 3
- },
- {
- "number": 2,
- "type": 0,
- "varint": 1007
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 4
- },
- {
- "number": 2,
- "type": 0,
- "varint": 1103
- }
- ]
- },
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 5
- },
- {
- "number": 2,
- "type": 0,
- "varint": 1434
- }
- ]
- }
- ]
- },
- "/MonsterHuntScheduleInfo": {
- "packet_code": 0,
- "fields": [
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 78
- },
- {
- "number": 2,
- "type": 0,
- "varint": 1789516800000
- },
- {
- "number": 3,
- "type": 0,
- "varint": 1790089199000
- },
- {
- "number": 6,
- "type": 0,
- "varint": 1
- }
- ]
- },
- {
- "number": 2,
- "type": 0,
- "varint": 73
- },
- {
- "number": 3,
- "type": 0,
- "varint": 6
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1790121599000
- },
- {
- "number": 7,
- "type": 0,
- "varint": 1
- }
- ]
- },
- {
- "number": 2,
- "type": 0,
- "varint": 5
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 1
- },
- {
- "number": 2,
- "type": 0,
- "varint": 1
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 2
- },
- {
- "number": 2,
- "type": 0,
- "varint": 2
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 3
- },
- {
- "number": 2,
- "type": 0,
- "varint": 3
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 4
- },
- {
- "number": 2,
- "type": 0,
- "varint": 4
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 5
- },
- {
- "number": 2,
- "type": 0,
- "varint": 5
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 6
- },
- {
- "number": 2,
- "type": 0,
- "varint": 6
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 7
- },
- {
- "number": 2,
- "type": 0,
- "varint": 7
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 8
- },
- {
- "number": 2,
- "type": 0,
- "varint": 8
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 9
- },
- {
- "number": 2,
- "type": 0,
- "varint": 9
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 10
- },
- {
- "number": 2,
- "type": 0,
- "varint": 10
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 11
- },
- {
- "number": 2,
- "type": 0,
- "varint": 11
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 12
- },
- {
- "number": 2,
- "type": 0,
- "varint": 12
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 13
- },
- {
- "number": 2,
- "type": 0,
- "varint": 13
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 14
- },
- {
- "number": 2,
- "type": 0,
- "varint": 14
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 15
- },
- {
- "number": 2,
- "type": 0,
- "varint": 15
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 16
- },
- {
- "number": 2,
- "type": 0,
- "varint": 16
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 17
- },
- {
- "number": 2,
- "type": 0,
- "varint": 17
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 18
- },
- {
- "number": 2,
- "type": 0,
- "varint": 18
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 19
- },
- {
- "number": 2,
- "type": 0,
- "varint": 19
- },
- {
- "number": 4,
- "type": 0,
- "varint": 1
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 20
- },
- {
- "number": 2,
- "type": 0,
- "varint": 20
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 21
- },
- {
- "number": 2,
- "type": 0,
- "varint": 21
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 22
- },
- {
- "number": 2,
- "type": 0,
- "varint": 14
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 23
- },
- {
- "number": 2,
- "type": 0,
- "varint": 23
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 24
- },
- {
- "number": 2,
- "type": 0,
- "varint": 24
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 25
- },
- {
- "number": 2,
- "type": 0,
- "varint": 25
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 26
- },
- {
- "number": 2,
- "type": 0,
- "varint": 19
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 27
- },
- {
- "number": 2,
- "type": 0,
- "varint": 26
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 28
- },
- {
- "number": 2,
- "type": 0,
- "varint": 27
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 29
- },
- {
- "number": 2,
- "type": 0,
- "varint": 28
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 30
- },
- {
- "number": 2,
- "type": 0,
- "varint": 29
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 31
- },
- {
- "number": 2,
- "type": 0,
- "varint": 30
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 32
- },
- {
- "number": 2,
- "type": 0,
- "varint": 31
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 33
- },
- {
- "number": 2,
- "type": 0,
- "varint": 32
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 34
- },
- {
- "number": 2,
- "type": 0,
- "varint": 33
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 35
- },
- {
- "number": 2,
- "type": 0,
- "varint": 34
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 36
- },
- {
- "number": 2,
- "type": 0,
- "varint": 35
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 37
- },
- {
- "number": 2,
- "type": 0,
- "varint": 36
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 38
- },
- {
- "number": 2,
- "type": 0,
- "varint": 37
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 39
- },
- {
- "number": 2,
- "type": 0,
- "varint": 38
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 40
- },
- {
- "number": 2,
- "type": 0,
- "varint": 39
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 41
- },
- {
- "number": 2,
- "type": 0,
- "varint": 40
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 42
- },
- {
- "number": 2,
- "type": 0,
- "varint": 41
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 43
- },
- {
- "number": 2,
- "type": 0,
- "varint": 42
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 44
- },
- {
- "number": 2,
- "type": 0,
- "varint": 43
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 45
- },
- {
- "number": 2,
- "type": 0,
- "varint": 44
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 46
- },
- {
- "number": 2,
- "type": 0,
- "varint": 45
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 47
- },
- {
- "number": 2,
- "type": 0,
- "varint": 46
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 48
- },
- {
- "number": 2,
- "type": 0,
- "varint": 47
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 49
- },
- {
- "number": 2,
- "type": 0,
- "varint": 48
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 50
- },
- {
- "number": 2,
- "type": 0,
- "varint": 49
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 51
- },
- {
- "number": 2,
- "type": 0,
- "varint": 50
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 52
- },
- {
- "number": 2,
- "type": 0,
- "varint": 51
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 53
- },
- {
- "number": 2,
- "type": 0,
- "varint": 52
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 54
- },
- {
- "number": 2,
- "type": 0,
- "varint": 53
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 55
- },
- {
- "number": 2,
- "type": 0,
- "varint": 54
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 56
- },
- {
- "number": 2,
- "type": 0,
- "varint": 55
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 57
- },
- {
- "number": 2,
- "type": 0,
- "varint": 56
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 58
- },
- {
- "number": 2,
- "type": 0,
- "varint": 57
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 59
- },
- {
- "number": 2,
- "type": 0,
- "varint": 59
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 60
- },
- {
- "number": 2,
- "type": 0,
- "varint": 60
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 61
- },
- {
- "number": 2,
- "type": 0,
- "varint": 58
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 62
- },
- {
- "number": 2,
- "type": 0,
- "varint": 55
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 63
- },
- {
- "number": 2,
- "type": 0,
- "varint": 61
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 64
- },
- {
- "number": 2,
- "type": 0,
- "varint": 62
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 65
- },
- {
- "number": 2,
- "type": 0,
- "varint": 59
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 66
- },
- {
- "number": 2,
- "type": 0,
- "varint": 63
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 67
- },
- {
- "number": 2,
- "type": 0,
- "varint": 64
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 68
- },
- {
- "number": 2,
- "type": 0,
- "varint": 65
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 69
- },
- {
- "number": 2,
- "type": 0,
- "varint": 66
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 70
- },
- {
- "number": 2,
- "type": 0,
- "varint": 67
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 71
- },
- {
- "number": 2,
- "type": 0,
- "varint": 68
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 72
- },
- {
- "number": 2,
- "type": 0,
- "varint": 69
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 73
- },
- {
- "number": 2,
- "type": 0,
- "varint": 68
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 74
- },
- {
- "number": 2,
- "type": 0,
- "varint": 70
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 75
- },
- {
- "number": 2,
- "type": 0,
- "varint": 65
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 76
- },
- {
- "number": 2,
- "type": 0,
- "varint": 71
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 77
- },
- {
- "number": 2,
- "type": 0,
- "varint": 72
- }
- ]
- },
- {
- "number": 3,
- "type": 2,
- "fields": [
- {
- "number": 1,
- "type": 0,
- "varint": 78
- },
- {
- "number": 2,
- "type": 0,
- "varint": 73
- }
- ]
- }
- ]
- },
"/PassInfo": {
"packet_code": 124,
"fields": [
diff --git a/go/seed/v2_35_10/schedule.json b/go/seed/v2_35_10/schedule.json
deleted file mode 100644
index 1b8381c..0000000
--- a/go/seed/v2_35_10/schedule.json
+++ /dev/null
@@ -1,163 +0,0 @@
-{
- "version": "2.35.10",
- "calculate_milliseconds": 32400000,
- "contents": [
- {
- "id": 1,
- "current": {
- "id": 160,
- "start_milliseconds": 1789344000000,
- "end_milliseconds": 1789916399000,
- "rank_reward_group_id": 1
- },
- "next": {
- "id": 161,
- "start_milliseconds": 1789948800000,
- "end_milliseconds": 1790521199000,
- "rank_reward_group_id": 1
- }
- },
- {
- "id": 2,
- "current": {
- "id": 58,
- "start_milliseconds": 1755129600000,
- "end_milliseconds": 253370678399000,
- "rank_reward_group_id": 1
- },
- "next": {
- "id": 58,
- "start_milliseconds": 1755129600000,
- "end_milliseconds": 253370678399000,
- "rank_reward_group_id": 1
- }
- },
- {
- "id": 3,
- "current": {
- "id": 129,
- "start_milliseconds": 1789657200000,
- "end_milliseconds": 1790262000000,
- "rank_reward_group_id": 1
- },
- "next": {
- "id": 999999,
- "start_milliseconds": 253370764800000,
- "end_milliseconds": 253370764800000,
- "rank_reward_group_id": 1
- }
- },
- {
- "id": 4,
- "current": {
- "id": 862,
- "start_milliseconds": 1789830000000,
- "end_milliseconds": 1789916400000,
- "rank_reward_group_id": 1
- },
- "next": {
- "id": 999999,
- "start_milliseconds": 253370764800000,
- "end_milliseconds": 253370764800000,
- "rank_reward_group_id": 1
- }
- },
- {
- "id": 5,
- "current": {
- "id": 29,
- "start_milliseconds": 1787788800000,
- "end_milliseconds": 1790089199000,
- "rank_reward_group_id": 1
- },
- "next": {
- "id": 30,
- "start_milliseconds": 1790121600000,
- "end_milliseconds": 1791385199000,
- "rank_reward_group_id": 1
- }
- },
- {
- "id": 6,
- "current": {
- "id": 26,
- "start_milliseconds": 1787788800000,
- "end_milliseconds": 1788361199000,
- "rank_reward_group_id": 1
- },
- "next": {
- "id": 27,
- "start_milliseconds": 1790121600000,
- "end_milliseconds": 1790693999000,
- "rank_reward_group_id": 1
- }
- },
- {
- "id": 7,
- "current": {
- "id": 14,
- "start_milliseconds": 1788220800000,
- "end_milliseconds": 1790812800000,
- "rank_reward_group_id": 1
- },
- "next": {
- "id": 999999,
- "start_milliseconds": 253370764800000,
- "end_milliseconds": 253370764800000,
- "rank_reward_group_id": 1
- }
- },
- {
- "id": 8,
- "current": {
- "id": 42,
- "start_milliseconds": 1789603200000,
- "end_milliseconds": 1790089199000,
- "rank_reward_group_id": 1
- },
- "next": {
- "id": 43,
- "start_milliseconds": 1790121600000,
- "end_milliseconds": 1790780399000,
- "rank_reward_group_id": 1
- }
- },
- {
- "id": 9,
- "current": {
- "id": 2,
- "start_milliseconds": 1787788800000,
- "end_milliseconds": 1792594799000,
- "rank_reward_group_id": 1
- },
- "next": {
- "id": 999999,
- "start_milliseconds": 253370764800000,
- "end_milliseconds": 253370764800000,
- "rank_reward_group_id": 1
- }
- }
- ],
- "regular": [
- {
- "content_id": 1,
- "season": 5
- },
- {
- "content_id": 2,
- "season": 6
- },
- {
- "content_id": 5,
- "season": 5
- },
- {
- "content_id": 8,
- "season": 0
- },
- {
- "content_id": 9,
- "season": 0
- }
- ]
-}
diff --git a/plugins/CashShop/AttendanceRewardEnvelope.cs b/plugins/CashShop/AttendanceRewardEnvelope.cs
new file mode 100644
index 0000000..0dd8940
--- /dev/null
+++ b/plugins/CashShop/AttendanceRewardEnvelope.cs
@@ -0,0 +1,35 @@
+using System;
+using Google.Protobuf;
+
+namespace Bd2CashShop;
+
+// Owned-server additions stay in protobuf unknown fields. The frozen client
+// AttendanceResponse/EventRewardResponse schemas and their handlers stay intact.
+internal static class AttendanceRewardEnvelope
+{
+ internal static bool TryRead(IMessage response, out ByteString rewards, out string receipt)
+ {
+ rewards = null;
+ receipt = null;
+ if (response == null || response.CalculateSize() > 8 * 1024 * 1024) return false;
+ using (var input = new CodedInputStream(response.ToByteArray()))
+ {
+ uint tag;
+ while ((tag = input.ReadTag()) != 0)
+ {
+ if (tag == (1001u << 3 | 2u))
+ {
+ if (rewards != null) return false;
+ rewards = input.ReadBytes();
+ }
+ else if (tag == (1002u << 3 | 2u))
+ {
+ if (receipt != null) return false;
+ receipt = input.ReadString();
+ }
+ else input.SkipLastField();
+ }
+ }
+ return rewards != null && !string.IsNullOrWhiteSpace(receipt) && receipt.Length <= 1024;
+ }
+}
diff --git a/plugins/CashShop/CashShop.csproj b/plugins/CashShop/CashShop.csproj
new file mode 100644
index 0000000..b90cb09
--- /dev/null
+++ b/plugins/CashShop/CashShop.csproj
@@ -0,0 +1,37 @@
+
+
+ netstandard2.1
+ latest
+ disable
+ BD2CashShop
+ Bd2CashShop
+ cash_shop
+ 2.35.10
+
+
+
+
+ $(BD2BepInExDir)\core\BepInEx.dll
+ false
+
+
+ $(BD2BepInExDir)\core\0Harmony.dll
+ false
+
+
+ $(BD2ManagedDir)\UnityEngine.CoreModule.dll
+ false
+
+
+ $(BD2ManagedDir)\UnityEngine.dll
+ false
+
+
+ $(BD2ManagedDir)\Newtonsoft.Json.dll
+ false
+
+ $(BD2ManagedDir)/Unity.TextMeshPro.dllfalse
+ $(BD2ManagedDir)/UnityEngine.UI.dllfalse
+ $(BD2ManagedDir)/Google.Protobuf.dllfalse
+
+
diff --git a/plugins/CashShop/CommerceCatalog.cs b/plugins/CashShop/CommerceCatalog.cs
new file mode 100644
index 0000000..6d6422b
--- /dev/null
+++ b/plugins/CashShop/CommerceCatalog.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace Bd2CashShop;
+
+internal sealed class CommerceCatalog
+{
+ [JsonProperty("schema_version", Required = Required.Always)] public int SchemaVersion { get; set; }
+ [JsonProperty("game_version", Required = Required.Always)] public string GameVersion { get; set; }
+ [JsonProperty("products", Required = Required.Always)] public CommerceProduct[] Products { get; set; }
+
+ internal Dictionary Validate(string gameVersion)
+ {
+ if (SchemaVersion != 1 || GameVersion != gameVersion || Products == null)
+ throw new InvalidOperationException("Commerce catalog version mismatch");
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (CommerceProduct product in Products)
+ {
+ if (product == null || string.IsNullOrWhiteSpace(product.Sku) || product.GroupId <= 0 || product.ProductId <= 0 ||
+ product.SaleGroup < 0 || product.Amount < 0 || product.ItemType != 0 && product.ItemType != 2 && product.ItemType != 3 && product.ItemType != 4 ||
+ product.ItemType == 0 && product.Amount != 0 || !product.Recharge && product.ItemType != 2 || result.ContainsKey(product.Key))
+ throw new InvalidOperationException("Invalid or duplicate commerce SKU");
+ result.Add(product.Key, product);
+ }
+ return result;
+ }
+}
+
+internal sealed class CommerceProduct
+{
+ internal string Key => MakeKey(GroupId, ProductId, SaleGroup, Sku);
+ internal static string MakeKey(int group, int id, int sale, string sku) => group + "/" + id + "/" + sale + "/" + sku;
+ [JsonProperty("sku", Required = Required.Always)] public string Sku { get; set; }
+ [JsonProperty("group_id", Required = Required.Always)] public int GroupId { get; set; }
+ [JsonProperty("product_id", Required = Required.Always)] public int ProductId { get; set; }
+ [JsonProperty("sale_group", Required = Required.Always)] public int SaleGroup { get; set; }
+ [JsonProperty("item_type", Required = Required.Always)] public int ItemType { get; set; }
+ [JsonProperty("amount", Required = Required.Always)] public long Amount { get; set; }
+ [JsonProperty("recharge", Required = Required.Always)] public bool Recharge { get; set; }
+ [JsonProperty("enabled", Required = Required.Always)] public bool Enabled { get; set; }
+}
diff --git a/plugins/CashShop/CommercePayments.cs b/plugins/CashShop/CommercePayments.cs
new file mode 100644
index 0000000..1cc6cae
--- /dev/null
+++ b/plugins/CashShop/CommercePayments.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace Bd2CashShop;
+
+internal static class CommercePayments
+{
+ private static readonly HashSet Applied = new HashSet();
+
+ internal static string Receipt(long payment, CommerceProduct quote) =>
+ "bd2-local-commerce-v1:" + payment.ToString(CultureInfo.InvariantCulture) + ":" +
+ quote.ItemType.ToString(CultureInfo.InvariantCulture) + ":" + quote.Amount.ToString(CultureInfo.InvariantCulture);
+
+ internal static Action AcceptOnce(long payment, Func isCurrentSession, Action debit, Action continuation)
+ {
+ return delegate
+ {
+ // The transport invokes this callback only on a successful response.
+ // A replay must not charge the displayed client balance twice.
+ if (isCurrentSession() && Applied.Add(payment)) debit();
+ continuation?.Invoke();
+ };
+ }
+
+ internal static void Clear() => Applied.Clear();
+}
diff --git a/plugins/CashShop/Plugin.cs b/plugins/CashShop/Plugin.cs
new file mode 100644
index 0000000..c6ffb04
--- /dev/null
+++ b/plugins/CashShop/Plugin.cs
@@ -0,0 +1,636 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Reflection;
+using System.Threading.Tasks;
+using BD2.GameNames;
+using BepInEx;
+using HarmonyLib;
+using Newtonsoft.Json;
+using TMPro;
+using UnityEngine;
+using gamfs.Platform;
+using gamfs;
+using BDNetwork;
+using Proto.Net;
+using Proto.Design.common;
+using Google.Protobuf;
+
+namespace Bd2CashShop;
+
+[BepInPlugin("bd2.cashshop", "BD2 Cash Shop", Bd2Build.Versions.Plugin)]
+[BepInDependency("bd2.localidentity")]
+public sealed class Plugin : BaseUnityPlugin
+{
+ private static Harmony harmony;
+ private static CommerceHost host;
+ private static bool applicationQuitting;
+ private static Dictionary products;
+ private static string catalogOrigin;
+ private static readonly Dictionary priceViews = new Dictionary();
+ private static readonly HashSet attendanceReceipts = new HashSet(StringComparer.Ordinal);
+ private static readonly HashSet attendanceRefreshPayments = new HashSet();
+ private sealed class PriceLabelState
+ {
+ internal string Text;
+ internal bool Active;
+ internal TextAlignmentOptions Alignment;
+ internal bool Wrap;
+ internal Vector2 AnchorMin, AnchorMax, Pivot, OffsetMin, OffsetMax;
+ internal bool Layout;
+ internal Dictionary Drivers;
+ }
+ private static readonly Dictionary> unavailablePriceLabels = new Dictionary>();
+ private static BepInEx.Logging.ManualLogSource Log;
+ private const string Unavailable = "当前服务器未开通购买功能";
+ private const string Marker = " Logger.LogInfo(message));
+ harmony = new Harmony("bd2.cashshop");
+ Patch(typeof(PlatformManager), "Purchase", nameof(Purchase), false, 3);
+ Patch(typeof(PlatformManager), "FinishPurchase", nameof(Suppress), false, 2);
+ Patch(typeof(PlatformManager), "RetryUnfinishedPurchase", nameof(Suppress), false, 0);
+ Patch(typeof(PlatformManager), "SendInAppEventPurchase", nameof(Suppress), false, 4);
+ Patch(typeof(CashShopPacket), "SendInAppEventPurchase", nameof(Suppress), false, 2);
+ Patch(typeof(CashShopPacket), "AddSubscribeAttendanceInfo", nameof(Suppress), false, 1);
+ Patch(typeof(CashShopPacket), "SendPlatformCashShopBuyRequest", nameof(BeforeRequest), false, 10);
+ Patch(typeof(PlatformManager), "GetProductMarketPriceWithSymbol", nameof(PriceLabel), false, 1);
+ Patch(typeof(PlatformManager), "GetProductMarketPriceOnly", nameof(PriceNumber), false, 1);
+ Patch(typeof(PlatformManager), "GetProductCurrencyCode", nameof(CurrencyCode), false, 1);
+ Patch(typeof(PlatformRuler), "GetProductAsync", nameof(ProductPrefetch), false, 6);
+ Patch(typeof(PlatformManager), "GetPriceLocalization", nameof(LocalPriceLocalization), false, 4);
+ Patch(typeof(PlatformRuler), "InitializeGPG", nameof(SkipOfficialBilling), false, 1);
+ Patch(typeof(PlatformRuler), "CheckGPGOAuthStatus", nameof(LocalBillingStatus), false, 1);
+ Patch(typeof(PlatformRuler), "OnGetPriceLocalizationBySplit", nameof(LocalSplitPrices), false, 4);
+ Patch(typeof(CurrencyButton), "SetCash", nameof(SetCash), false, 1);
+ Patch(typeof(CashShopBuyPopupUI), "SetProductInfo", nameof(PopupPrice), true, 8);
+ MethodInfo paymentPopup = typeof(UIManager).GetGameMethod("OpenSelectiveMessagePopupUI", All, null,
+ new[] { typeof(Action), typeof(string), typeof(string), typeof(string),
+ typeof(Action), typeof(bool), typeof(bool), typeof(MessagePopupUI.EMessagePopupButtonStyle) }, null);
+ if (paymentPopup == null) throw new MissingMethodException("UIManager payment confirmation overload");
+ harmony.Patch(paymentPopup, prefix: new HarmonyMethod(typeof(Plugin), nameof(SuppressExternalPaymentConfirmation)));
+ Patch(typeof(PackagePurchaseButton), "SetPrice", nameof(PackagePrice), true, 0);
+ Patch(typeof(PassRootUI), "CheckIsInAppBuyButton", nameof(PassPrice), true, 0);
+ Patch(typeof(BuyConfirmPopupUI), "SetPassBuyPopup", nameof(PassPopupPrice), true, 5);
+ Patch(typeof(GachaProductButtonElement), "Set", nameof(GachaPrice), true, 6);
+ PatchGeneratedReceiver("b__0", nameof(LoginPassRewards));
+ PatchGeneratedReceiver("b__0", nameof(EventRewards));
+ MethodInfo attendance = typeof(EventPacket).GetGameMethod("RecvAttendanceResponse", All, null,
+ new[] { typeof(IMessage), typeof(int), typeof(int) }, null);
+ if (attendance == null) throw new MissingMethodException("EventPacket.RecvAttendanceResponse(IMessage,int,int)");
+ harmony.Patch(attendance, postfix: new HarmonyMethod(typeof(Plugin), nameof(AttendanceRewards)));
+ Patch(typeof(IntroUI), "Awake", nameof(IntroAwake), true, 0);
+ EnsureCommerceHost();
+ Logger.LogInfo("Cash shop runtime installed: persistent catalog host and server-owned billing hooks");
+ }
+ catch (Exception exception)
+ {
+ products = null;
+ Logger.LogError("Cash shop initialization failed: " + exception);
+ }
+ }
+
+ private void Patch(Type type, string name, string callback, bool postfix, int count)
+ {
+ MethodInfo target = type.GetMethods(All).Single(method => method.IsGameMethod(name) && method.GetParameters().Length == count);
+ var patch = new HarmonyMethod(typeof(Plugin), callback);
+ harmony.Patch(target, prefix: postfix ? null : patch, postfix: postfix ? patch : null);
+ }
+
+ private void PatchGeneratedReceiver(string readableName, string callback)
+ {
+ // Compiler-generated declaring types are found by their mapped methods;
+ // neither an obfuscated type name nor a generated class number is pinned.
+ MethodInfo target = typeof(EventPacket).GetNestedTypes(All)
+ .SelectMany(type => type.GetMethods(All | BindingFlags.DeclaredOnly))
+ .Single(method => method.IsGameMethod(readableName) && method.ReturnType == typeof(bool) &&
+ method.GetParameters().Select(parameter => parameter.ParameterType)
+ .SequenceEqual(new[] { typeof(byte[]), typeof(int), typeof(int) }));
+ harmony.Patch(target, postfix: new HarmonyMethod(typeof(Plugin), callback));
+ }
+
+ private static string Origin()
+ {
+ var uri = typeof(ServerURLInfo).GetGameField(nameof(ServerURLInfo.MaintenanceUri), All).GetValue(null) as Uri;
+ if (uri == null || uri.UserInfo.Length != 0 || uri.Scheme != "http" && uri.Scheme != "https")
+ throw new InvalidOperationException("Commerce server origin unavailable");
+ return uri.GetLeftPart(UriPartial.Authority);
+ }
+
+ private static void IntroAwake() => EnsureCommerceHost();
+
+ private static void EnsureCommerceHost()
+ {
+ if (applicationQuitting || host != null && host.gameObject.activeInHierarchy) return;
+ if (host != null) UnityEngine.Object.Destroy(host.gameObject);
+ var owner = new GameObject("BD2 Cash Shop Host");
+ UnityEngine.Object.DontDestroyOnLoad(owner);
+ host = owner.AddComponent();
+ Log?.LogInfo("Commerce runtime host created: reason=startup-or-intro-restoration");
+ host.StartCoroutine(RefreshCatalog());
+ }
+
+ public sealed class CommerceHost : MonoBehaviour
+ {
+ private void OnApplicationQuit() { applicationQuitting = true; }
+ private void OnDestroy()
+ {
+ if (ReferenceEquals(host, this)) host = null;
+ Log?.LogInfo("Commerce runtime host stopped: reason=" + (applicationQuitting ? "application-quit" : "scene-destruction-awaiting-intro-restoration"));
+ }
+ }
+
+ private static IEnumerator RefreshCatalog()
+ {
+ float nextFetch = 0;
+ while (true)
+ {
+ string origin = null;
+ try { origin = Origin(); } catch { }
+ if (origin != catalogOrigin) { products = null; catalogOrigin = origin; nextFetch = 0; RefreshPrices(); }
+ if (origin != null && Time.realtimeSinceStartup >= nextFetch)
+ {
+ Task> request = Task.Run(() => Fetch(origin));
+ while (!request.IsCompleted)
+ {
+ string current = null;
+ try { current = Origin(); } catch { }
+ if (current != origin) { products = null; }
+ yield return null;
+ }
+ string after = null;
+ try { after = Origin(); } catch { }
+ if (request.IsFaulted) { products = null; Log?.LogWarning("Commerce catalog unavailable: " + request.Exception.GetBaseException().Message); }
+ else if (after == origin) { products = request.Result; Log?.LogInfo("Loaded commerce policy for " + products.Count + " SKUs"); }
+ RefreshPrices();
+ nextFetch = Time.realtimeSinceStartup + (products == null ? 3 : 30);
+ }
+ yield return new WaitForSecondsRealtime(1);
+ }
+ }
+
+ private static Dictionary Fetch(string origin)
+ {
+ var request = (HttpWebRequest)WebRequest.Create(origin + "/client/commerce");
+ request.Method = "GET";
+ request.Timeout = 10000;
+ request.ReadWriteTimeout = 10000;
+ request.AllowAutoRedirect = false;
+ string proxy = Environment.GetEnvironmentVariable("BD2_CLIENT_PROXY_URL");
+ if (new Uri(origin).IsLoopback || string.IsNullOrWhiteSpace(proxy)) request.Proxy = null;
+ else
+ {
+ if (!Uri.TryCreate(proxy, UriKind.Absolute, out var proxyUri) || proxyUri.Scheme != "http" || proxyUri.UserInfo.Length != 0 || proxyUri.AbsolutePath != "/" || proxyUri.Query.Length != 0 || proxyUri.Fragment.Length != 0)
+ throw new InvalidOperationException("Unsupported client commerce proxy");
+ request.Proxy = new WebProxy(proxyUri);
+ }
+ using (var response = (HttpWebResponse)request.GetResponse())
+ using (var stream = response.GetResponseStream())
+ using (var reader = new StreamReader(stream))
+ {
+ if (response.StatusCode != HttpStatusCode.OK) throw new IOException("Commerce HTTP status " + response.StatusCode);
+ var bodyBuilder = new System.Text.StringBuilder();
+ var buffer = new char[8192];
+ int read;
+ while ((read = reader.Read(buffer, 0, buffer.Length)) != 0)
+ {
+ if (bodyBuilder.Length + read > 4 * 1024 * 1024) throw new IOException("Commerce catalog too large");
+ bodyBuilder.Append(buffer, 0, read);
+ }
+ string body = bodyBuilder.ToString();
+ return JsonConvert.DeserializeObject(body).Validate(Bd2Build.Versions.Game);
+ }
+ }
+
+ private static CommerceProduct Resolve(string sku)
+ {
+ try
+ {
+ if (products == null || catalogOrigin != Origin() || sku == null) return null;
+ CashProductTable table = CashShopInfo.GetCashProductDTO(sku, SG.App.MarketType);
+ if (table == null || !products.TryGetValue(CommerceProduct.MakeKey(table.GroupId, table.Id, table.SaleGroup, sku), out var policy)) return null;
+ if (table == null || table.PriceType != 1 || table.GroupId != policy.GroupId || table.Id != policy.ProductId || table.SaleGroup != policy.SaleGroup ||
+ CashShopInfo.GetProductIdByMarket(table, SG.App.MarketType) != sku) return null;
+ return policy;
+ }
+ catch { return null; }
+ }
+
+ private static CommerceProduct Resolve(int group, int id, int sale)
+ {
+ try
+ {
+ var table = CashShopInfo.GetCashProductDTO(group, id, sale);
+ if (table == null || table.PriceType != 1 || products == null || catalogOrigin != Origin()) return null;
+ string sku = CashShopInfo.GetProductIdByMarket(table, SG.App.MarketType);
+ return products.TryGetValue(CommerceProduct.MakeKey(group, id, sale, sku), out var policy) ? policy : null;
+ }
+ catch { return null; }
+ }
+
+ private static bool Purchase(string __0, object __1, Action __2)
+ {
+ var policy = Resolve(__0);
+ if (policy == null || !policy.Enabled)
+ {
+ __2?.Invoke();
+ ShowPurchaseFailure();
+ return false;
+ }
+ if (!HasFunds(policy))
+ {
+ __2?.Invoke();
+ InsufficientFunds(policy);
+ return false;
+ }
+ long pay = System.Threading.Interlocked.Increment(ref paySequence);
+ var result = new PurchaseData(__0, pay, "bd2-local:" + pay, CommercePayments.Receipt(pay, policy));
+ (__1 as Delegate)?.DynamicInvoke(result);
+ return false;
+ }
+
+ private static bool Suppress() => false;
+ private static bool ProductPrefetch(Action __3) { __3?.Invoke(); return false; }
+ private static bool LocalPriceLocalization(Action __1)
+ {
+ Log?.LogInfo("Commerce SDK suppressed: operation=price-localization reason=server-owned-cash-shop");
+ __1?.Invoke();
+ return false;
+ }
+ private static bool SkipOfficialBilling()
+ {
+ Log?.LogInfo("Commerce SDK suppressed: operation=gpg-billing-initialization reason=server-owned-cash-shop");
+ return false;
+ }
+ private static bool LocalBillingStatus(Action __0)
+ {
+ Log?.LogInfo("Commerce SDK suppressed: operation=gpg-oauth-status reason=server-owned-cash-shop");
+ __0?.Invoke(false);
+ return false;
+ }
+ private static bool LocalSplitPrices(Action __2)
+ {
+ Log?.LogInfo("Commerce SDK suppressed: operation=gpg-split-price-localization reason=server-owned-cash-shop");
+ __2?.Invoke();
+ return false;
+ }
+ private static bool PriceNumber(ref string __result) { __result = "0"; return false; }
+ private static bool CurrencyCode(ref string __result) { __result = "BD2"; return false; }
+
+ private static bool BeforeRequest(int __0, int __1, int __2, long __3, string __5, ref Action __7, Action __8, ref Action __9)
+ {
+ CommerceProduct quote = Resolve(__0, __1, __2);
+ if (quote == null || !quote.Enabled || quote.Sku != __5)
+ {
+ __8?.Invoke(1);
+ ShowPurchaseFailure();
+ return false;
+ }
+ if (!HasFunds(quote))
+ {
+ __8?.Invoke(1);
+ InsufficientFunds(quote);
+ return false;
+ }
+ // Cash products retain PriceType=Cash, so the original success handler has
+ // no local subtraction. Apply the accepted quote once, only after success.
+ Action continuation = __7;
+ long payment = __3;
+ string origin = catalogOrigin;
+ object user = CommonPacket.UserInfo;
+ __7 = CommercePayments.AcceptOnce(payment, () => origin == catalogOrigin && ReferenceEquals(user, CommonPacket.UserInfo), delegate
+ {
+ if (quote.Amount > 0)
+ {
+ CommonPacket.RemoveHaveCurrency((EElementType)quote.ItemType, quote.Amount, false, true);
+ UIManager.UpdateUseCurrency((EElementType)quote.ItemType, ECurrencyDirectDisplayType.FadeOutOnlySubtractionCurrency, quote.Amount, 0, null, false);
+ }
+ }, continuation);
+ // The server has already supplied the real ticket expiry. Prevent the
+ // client's additional +30-day estimate and reload authoritative claims.
+ Action bought = __9;
+ __9 = delegate
+ {
+ bought?.Invoke();
+ if (origin == catalogOrigin && ReferenceEquals(user, CommonPacket.UserInfo) && attendanceRefreshPayments.Add(payment))
+ RefreshAttendanceIndependent();
+ };
+ return true;
+ }
+
+ private static bool HasFunds(CommerceProduct quote) => quote.Amount == 0 ||
+ CommonPacket.UserInfo != null && CommonPacket.GetHaveCurrencyCount((EElementType)quote.ItemType, false) >= quote.Amount;
+
+ private static void ShowPurchaseFailure() =>
+ UIManager.OpenSelectiveMessagePopupUI(Unavailable, "购买失败", MessagePopupUI.EMessagePopupButtonStyle.ONLY_OK);
+
+ private static bool SuppressExternalPaymentConfirmation(string __1, string __2)
+ {
+ // Only the official cash checkout prompt is removed. Its callers proceed
+ // synchronously to our Purchase prefix, which opens the failure dialog
+ // for disabled goods or performs the server-owned purchase for enabled goods.
+ return __1 != LocalTextInfo.GetText(13104) || __2 != LocalTextInfo.GetText(13103);
+ }
+
+ private static void InsufficientFunds(CommerceProduct quote)
+ {
+ if (quote.ItemType == 2 || quote.ItemType == 3)
+ UIManager.OpenCashShopLinkPopupUI((EElementType)quote.ItemType, 0, ShopLinkPopupUI.EExitFieldMapType.None, null, null, null);
+ else UIManager.OpenSelectiveMessagePopupUI(LocalTextInfo.GetText(154), "", MessagePopupUI.EMessagePopupButtonStyle.ONLY_OK);
+ }
+
+ private static bool PriceLabel(string __0, ref string __result)
+ {
+ var policy = Resolve(__0);
+ string text = policy == null || !policy.Enabled ? Unavailable : policy.ItemType == 0 ? "Free" : policy.Amount.ToString(CultureInfo.InvariantCulture);
+ __result = Marker + Uri.EscapeDataString(__0 ?? "") + ">" + text + "";
+ return false;
+ }
+
+ private static CommerceProduct FromLabel(string label)
+ {
+ if (label == null) return null;
+ int start = label.IndexOf(Marker, StringComparison.Ordinal);
+ if (start < 0) return null;
+ start += Marker.Length;
+ int end = label.IndexOf('>', start);
+ return end < 0 ? null : Resolve(Uri.UnescapeDataString(label.Substring(start, end - start)));
+ }
+
+ private static bool SetCash(CurrencyButton __instance, string __0)
+ {
+ RestorePricePrefixes(__instance);
+ if (__0 == null || !__0.Contains(Marker)) return true;
+ string sku = ExtractSku(__0);
+ priceViews[__instance] = () => Render(__instance, Resolve(sku));
+ Render(__instance, FromLabel(__0));
+ return false;
+ }
+
+ private static void Render(CurrencyButton button, CommerceProduct policy)
+ {
+ if (button == null) return;
+ RestorePricePrefixes(button);
+ button.SetActive(true);
+ if (policy == null || !policy.Enabled || policy.ItemType == 0)
+ {
+ button.SetCash(policy != null && policy.Enabled ? "Free" : Unavailable);
+ return;
+ }
+ button.Set((EElementType)policy.ItemType, 0, policy.Amount);
+ button.SetEnough(CommonPacket.GetHaveCurrencyCount((EElementType)policy.ItemType, false) >= policy.Amount);
+ }
+
+ private static void RestorePricePrefixes(CurrencyButton button)
+ {
+ if (button == null || !unavailablePriceLabels.TryGetValue(button, out var saved)) return;
+ foreach (var entry in saved)
+ if (entry.Key != null)
+ {
+ entry.Key.text = entry.Value.Text;
+ entry.Key.gameObject.SetActive(entry.Value.Active);
+ if (entry.Value.Layout)
+ {
+ entry.Key.alignment = entry.Value.Alignment;
+ entry.Key.enableWordWrapping = entry.Value.Wrap;
+ RectTransform rect = entry.Key.rectTransform;
+ rect.anchorMin = entry.Value.AnchorMin; rect.anchorMax = entry.Value.AnchorMax;
+ rect.pivot = entry.Value.Pivot; rect.offsetMin = entry.Value.OffsetMin; rect.offsetMax = entry.Value.OffsetMax;
+ if (entry.Value.Drivers != null)
+ foreach (var driver in entry.Value.Drivers) if (driver.Key != null) driver.Key.enabled = driver.Value;
+ }
+ }
+ unavailablePriceLabels.Remove(button);
+ }
+
+ private static void PopupPrice(CashShopBuyPopupUI __instance, EElementType __3, string __7)
+ {
+ CurrencyButton button = Field(__instance, "_currencyButton");
+ if (button == null) return;
+ RestorePricePrefixes(button);
+ priceViews.Remove(button);
+ if (__3 != EElementType.Cash) return;
+ string sku = __7;
+ priceViews[button] = () => { Render(button, Resolve(sku)); ShowUnavailablePopupPrice(__instance, button, Resolve(sku)); };
+ ShowUnavailablePopupPrice(__instance, button, Resolve(sku));
+ }
+
+ private static void ShowUnavailablePopupPrice(CashShopBuyPopupUI popup, CurrencyButton button, CommerceProduct policy)
+ {
+ if (policy != null && policy.Enabled) return;
+ // CurrencyParent is the gray price strip. Never search its parent,
+ // which also contains the separate confirmation and cancellation buttons.
+ Transform container = button.transform;
+ GameObject buy = Field(popup, "_objBuyButton"), cancel = Field(popup, "_objCancelButton");
+ TMP_Text price = button.TextPrice;
+ var saved = new Dictionary();
+ foreach (var label in container.GetComponentsInChildren(true))
+ {
+ if (label == price || buy != null && label.transform.IsChildOf(buy.transform) || cancel != null && label.transform.IsChildOf(cancel.transform)) continue;
+ if (label.text != "购买费用") continue;
+ RectTransform rect = label.rectTransform;
+ saved[label] = new PriceLabelState { Text = label.text, Active = label.gameObject.activeSelf,
+ Layout = true, Alignment = label.alignment, Wrap = label.enableWordWrapping,
+ AnchorMin = rect.anchorMin, AnchorMax = rect.anchorMax, Pivot = rect.pivot,
+ OffsetMin = rect.offsetMin, OffsetMax = rect.offsetMax };
+ var drivers = new Dictionary();
+ foreach (var group in container.GetComponentsInChildren(true))
+ { drivers[group] = group.enabled; group.enabled = false; }
+ foreach (var fitter in container.GetComponentsInChildren(true))
+ { drivers[fitter] = fitter.enabled; fitter.enabled = false; }
+ saved[label].Drivers = drivers;
+ label.text = Unavailable;
+ label.gameObject.SetActive(true);
+ label.alignment = TextAlignmentOptions.Center;
+ label.enableWordWrapping = false;
+ rect.anchorMin = Vector2.zero; rect.anchorMax = Vector2.one; rect.pivot = new Vector2(0.5f, 0.5f);
+ rect.offsetMin = Vector2.zero; rect.offsetMax = Vector2.zero;
+ }
+ if (saved.Count == 0)
+ {
+ Log?.LogWarning("Commerce price layout: fee label missing inside CurrencyParent; centering existing price label");
+ if (price == null) return;
+ RectTransform rect = price.rectTransform;
+ saved[price] = new PriceLabelState { Text = price.text, Active = price.gameObject.activeSelf,
+ Layout = true, Alignment = price.alignment, Wrap = price.enableWordWrapping,
+ AnchorMin = rect.anchorMin, AnchorMax = rect.anchorMax, Pivot = rect.pivot,
+ OffsetMin = rect.offsetMin, OffsetMax = rect.offsetMax };
+ var drivers = new Dictionary();
+ foreach (var group in container.GetComponentsInChildren(true))
+ { drivers[group] = group.enabled; group.enabled = false; }
+ foreach (var fitter in container.GetComponentsInChildren(true))
+ { drivers[fitter] = fitter.enabled; fitter.enabled = false; }
+ saved[price].Drivers = drivers;
+ price.text = Unavailable;
+ price.gameObject.SetActive(true);
+ price.alignment = TextAlignmentOptions.Center;
+ price.enableWordWrapping = false;
+ rect.anchorMin = Vector2.zero; rect.anchorMax = Vector2.one; rect.pivot = new Vector2(0.5f, 0.5f);
+ rect.offsetMin = Vector2.zero; rect.offsetMax = Vector2.zero;
+ unavailablePriceLabels[button] = saved;
+ return;
+ }
+ if (price != null)
+ {
+ saved[price] = new PriceLabelState { Text = price.text, Active = price.gameObject.activeSelf };
+ price.gameObject.SetActive(false);
+ }
+ unavailablePriceLabels[button] = saved;
+ }
+
+ private static T Field(object target, string name)
+ {
+ for (Type type = target.GetType(); type != null; type = type.BaseType)
+ {
+ FieldInfo field = type.GetGameField(name, All | BindingFlags.DeclaredOnly);
+ if (field != null) return (T)field.GetValue(target);
+ }
+ throw new MissingFieldException(target.GetType().FullName, name);
+ }
+
+ private static void PackagePrice(PackagePurchaseButton __instance)
+ {
+ priceViews.Remove(__instance);
+ int group = Field(__instance, "_productGroupId"), id = Field(__instance, "_productId"), sale = Field(__instance, "_productSaleGroupId");
+ var table = CashShopInfo.GetCashProductDTO(group, id, sale);
+ if (table == null || table.PriceType != 1) return;
+ var button = Field(__instance, "_btnCurrency");
+ priceViews[__instance] = () => PackagePrice(__instance);
+ CommerceProduct policy = Resolve(group, id, sale);
+ TMP_Text label = Field(__instance, "_textPrice");
+ RestorePricePrefixes(button);
+ if (policy == null || !policy.Enabled)
+ {
+ if (button != null) button.SetActive(false);
+ if (label != null) { label.text = Unavailable; label.gameObject.SetActive(true); }
+ return;
+ }
+ Render(button, policy);
+ if (button != null) label?.gameObject.SetActive(false);
+ }
+
+ private static void PassPrice(PassRootUI __instance, bool __result)
+ {
+ priceViews.Remove(__instance);
+ if (!__result) return;
+ var table = PassInfo.GetPassBuyTable(Field(__instance, "_curPassTableID"), Define_PassBuyType.PbPremium2);
+ priceViews[__instance] = () => PassPrice(__instance, true);
+ Render(Field(__instance, "_imgPremiumBuyPrice"), Field(__instance, "_txtPremiumBuyPrice"), Resolve(table.CashProductGroupId, table.CashProductId, table.CashSalesGroup));
+ }
+
+ private static void PassPopupPrice(BuyConfirmPopupUI __instance, int __0, Define_PassBuyType __1)
+ {
+ priceViews.Remove(__instance);
+ if (__1 != Define_PassBuyType.PbPremium2) return;
+ var table = PassInfo.GetPassBuyTable(__0, __1);
+ int pass = __0;
+ Define_PassBuyType buy = __1;
+ priceViews[__instance] = () => PassPopupPrice(__instance, pass, buy);
+ Render(Field(__instance, "_uiSpriteCurrency"), Field(__instance, "_textCurrency"), Resolve(table.CashProductGroupId, table.CashProductId, table.CashSalesGroup));
+ }
+
+ private static void Render(UISprite sprite, TMP_Text text, CommerceProduct policy)
+ {
+ if (sprite != null)
+ {
+ sprite.gameObject.SetActive(policy != null && policy.Enabled && policy.ItemType != 0);
+ if (policy != null && policy.Enabled && policy.ItemType != 0) sprite.SetSprite(CurrencyInfo.GetCurrencyDTO((EElementType)policy.ItemType).IconSpriteNameSmall);
+ }
+ if (text != null) text.text = policy == null || !policy.Enabled ? Unavailable : policy.ItemType == 0 ? "Free" : policy.Amount.ToString(CultureInfo.InvariantCulture);
+ }
+
+ private static void GachaPrice(GachaProductButtonElement __instance, string __4)
+ {
+ var buttons = Field(__instance, "_currencyGachas");
+ if (buttons == null || buttons.Length == 0) return;
+ foreach (var button in buttons) if (button != null) priceViews.Remove(button);
+ if (__4 == null || !__4.Contains(Marker)) return;
+ string sku = ExtractSku(__4);
+ priceViews[buttons[0]] = () => Render(buttons[0], Resolve(sku));
+ foreach (var button in buttons) button?.SetActive(false);
+ Render(buttons[0], FromLabel(__4));
+ Field(__instance, "_textDescription").text = "";
+ }
+
+ private static void AttendanceRewards(IMessage __0, int __2, bool __result)
+ {
+ if (__2 != 0 || !__result) return;
+ try
+ {
+ if (!AttendanceRewardEnvelope.TryRead(__0, out ByteString encoded, out string receipt)) return;
+ RewardDBInfoBundle bundle = RewardDBInfoBundle.Parser.ParseFrom(encoded);
+ string key = Origin() + "/" + receipt;
+ if (!attendanceReceipts.Add(key)) return;
+ var rewards = new List();
+ DataManager.AddRewardInfoBundle(bundle, ref rewards, true);
+ UIManager.RefreshCurrencyUI();
+ UIManager.OpenCharNoticeUI(bundle.CharInfo, bundle.ItemAutoExchangeInfo, bundle.ItemAutoUpgradeInfo);
+ }
+ catch (Exception error) { Log?.LogWarning("Attendance commerce rewards could not be applied: " + error.Message); }
+ }
+
+ private static void LoginPassRewards(byte[] __0, int __2, bool __result)
+ {
+ if (__2 == 0 && __result) ApplyRewardEnvelope(AttendanceResponse.Parser.ParseFrom(__0));
+ }
+
+ private static void EventRewards(byte[] __0, int __2, bool __result)
+ {
+ if (__2 == 0 && __result) ApplyRewardEnvelope(EventRewardResponse.Parser.ParseFrom(__0));
+ }
+
+ private static void ApplyRewardEnvelope(IMessage message) => AttendanceRewards(message, 0, true);
+
+ private static void RefreshAttendanceIndependent()
+ {
+ // Own the receiver rather than setting EventPacket's single static
+ // onAttendanceRespSuccess slot, which may belong to an in-flight UI.
+ string origin = catalogOrigin;
+ object user = CommonPacket.UserInfo;
+ SG.Net.Send(new AttendanceRequest { Seq = BDNetwork.Sequence.ReqSeq }, (data, packetCode, error) =>
+ {
+ if (error != 0 || origin != catalogOrigin || !ReferenceEquals(user, CommonPacket.UserInfo)) return false;
+ AttendanceResponse response = AttendanceResponse.Parser.ParseFrom(data);
+ CashShopPacket.RefreshAllAttendanceInfo(response.AttendanceInfo);
+ CashShopPacket.RefreshSubscribeAttendanceInfo(response.SubscribeAttendanceInfo);
+ CashShopPacket.RefreshAttendancePackageInfo(response.AttendancePackageInfo);
+ CashShopPacket.RefreshLoginPassPackageInfo(response.LoginPassPackageInfo);
+ ApplyRewardEnvelope(response);
+ return true;
+ }, false, null, false, null);
+ }
+
+ private static string ExtractSku(string label)
+ {
+ int start = label.IndexOf(Marker, StringComparison.Ordinal) + Marker.Length;
+ int end = label.IndexOf('>', start);
+ return end < 0 ? null : Uri.UnescapeDataString(label.Substring(start, end - start));
+ }
+
+ private static void RefreshPrices()
+ {
+ foreach (var view in priceViews.ToArray())
+ {
+ if (view.Key == null) { priceViews.Remove(view.Key); continue; }
+ try { view.Value(); } catch (Exception error) { Log?.LogWarning("Commerce UI refresh: " + error.Message); }
+ }
+ }
+
+ private void OnDestroy()
+ {
+ // Unity may remove the BepInEx component during startup. Static hooks and
+ // the separate persistent host must survive that scene cleanup.
+ Log?.LogInfo("Cash shop plugin component destroyed: billing hooks retained on persistent runtime");
+ }
+}
diff --git a/plugins/CashShop/README.md b/plugins/CashShop/README.md
new file mode 100644
index 0000000..22b38fb
--- /dev/null
+++ b/plugins/CashShop/README.md
@@ -0,0 +1,17 @@
+# BD2 Cash Shop
+
+把当前游戏的现金支付入口替换为本服的游戏内货币购买。插件依赖 `BD2LocalIdentity.dll`,目标游戏版本在项目中明确声明为 `2.35.10`,使用共享 `BD2.GameNames.dll` 和 GameSdk 构建。
+
+普通现金商品固定使用付费钻石。充值商品的获取方式由服务端 `game.json` 的 `purchases.diamond_recharge` 设置:默认免费,也可使用金币、普通钻石,或者禁止充值。完整配置和换算说明见 [GAME_CONFIGURATION.md](../../GAME_CONFIGURATION.md#现金商品与付费钻石充值)。
+
+客户端从当前服务器 `/client/commerce` 获取完整价格目录。配置未加载、版本不匹配或禁止购买时,购买入口会显示游戏提示框,不会调用外部支付 SDK。收费商品使用游戏原有货币图标与数量;免费充值显示 `Free`。
+
+补丁保留原购买回调,因此无限抽最终确认、特殊甄选券、月卡、礼包、皮肤和现金通行证继续执行各自的游戏流程。原本使用普通钻石、付费钻石、金币等游戏内货币的商品不修改价格类型。服务器独立验证商品、价格、余额、购买次数、开放时间和接力前置条件,并在一个存档事务内完成扣款与发奖;客户端发送的价格仅用于检测过期目录。
+
+月卡、登录通行礼包和付费签到的领取状态在服务器保存,`AttendanceResponse` 与 `EventRewardResponse` 的本服扩展字段同步每日奖励并用回执防止重复应用。现金通行证激活必须消费购买权益。主线通关礼包按服务器主线进度领取;恶魔城奖励需要对应玩法提供真实完成记录,目前占位玩法不能领取通关奖励。
+
+```powershell
+dotnet build plugins/CashShop/CashShop.csproj -c Release
+```
+
+客户端开发入口和发布包会同时构建、安装此插件。手动部署时使用 `bin/Release/netstandard2.1/BD2CashShop.dll`,与 LocalIdentity 及共享运行时一起放入本服游戏的 `BepInEx/plugins`。原版客户端与官方抓包环境不要安装这个插件。
diff --git a/plugins/Generate-VersionSource.ps1 b/plugins/Generate-VersionSource.ps1
index 5316113..aaaf6e4 100644
--- a/plugins/Generate-VersionSource.ps1
+++ b/plugins/Generate-VersionSource.ps1
@@ -2,7 +2,7 @@
param(
[Parameter(Mandatory)] [string]$Config,
[Parameter(Mandatory)] [string]$Output,
- [Parameter(Mandatory)] [ValidateSet('local_identity', 'capture_environment', 'login_ui')] [string]$Plugin
+ [Parameter(Mandatory)] [ValidateSet('local_identity', 'capture_environment', 'login_ui', 'cash_shop')] [string]$Plugin
)
$ErrorActionPreference = 'Stop'
diff --git a/plugins/LocalIdentity/LocalAccountPolicy.cs b/plugins/LocalIdentity/LocalAccountPolicy.cs
index 15c40aa..688ef05 100644
--- a/plugins/LocalIdentity/LocalAccountPolicy.cs
+++ b/plugins/LocalIdentity/LocalAccountPolicy.cs
@@ -5,7 +5,6 @@ using System.Reflection;
using BepInEx.Logging;
using HarmonyLib;
using UnityEngine;
-using System.Linq;
namespace Bd2LocalIdentity;
@@ -81,83 +80,4 @@ internal static class LocalAccountPolicy
};
}
- internal static void InstallLocalPurchaseBypass(Harmony harmony)
- {
- Type platformRuler = typeof(gamfs.Platform.PlatformRuler);
- MethodInfo getProducts = platformRuler?
- .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
- .SingleOrDefault(method =>
- method.IsGameMethod(nameof(gamfs.Platform.PlatformRuler.GetProductAsync)) &&
- method.GetParameters().Length == 6 &&
- method.GetParameters()[0].ParameterType == typeof(string[]) &&
- method.GetParameters()[3].ParameterType == typeof(Action) &&
- method.GetParameters()[4].ParameterType == typeof(Action) &&
- method.GetParameters()[5].ParameterType == typeof(bool));
- if (getProducts == null)
- {
- throw new MissingMethodException("PlatformRuler.GetProductAsync was not found");
- }
- harmony.Patch(
- getProducts,
- prefix: new HarmonyMethod(typeof(LocalAccountPolicy), nameof(GetProductsPrefix)));
-
- Type platformManager = typeof(gamfs.Platform.PlatformManager);
- MethodInfo purchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
- .SingleOrDefault(method => method.IsGameMethod(nameof(gamfs.Platform.PlatformManager.Purchase)) &&
- method.GetParameters().Length == 3 &&
- method.GetParameters()[0].ParameterType == typeof(string) &&
- method.GetParameters()[2].ParameterType == typeof(Action));
- MethodInfo finishPurchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
- .SingleOrDefault(method => method.IsGameMethod(nameof(gamfs.Platform.PlatformManager.FinishPurchase)) &&
- method.GetParameters().Length == 2 &&
- method.GetParameters()[0].ParameterType == typeof(string) &&
- method.GetParameters()[1].ParameterType == typeof(long));
- if (purchase == null || finishPurchase == null)
- throw new MissingMethodException("PlatformManager purchase methods were not found");
- harmony.Patch(purchase, prefix: new HarmonyMethod(
- typeof(LocalAccountPolicy), nameof(LocalPurchasePrefix)));
- harmony.Patch(finishPurchase, prefix: new HarmonyMethod(
- typeof(LocalAccountPolicy), nameof(FinishLocalPurchasePrefix)));
- Log?.LogInfo("Local purchase price lookup disabled");
- Log?.LogInfo("Infinite reroll confirmation is local/free; all other paid purchases are blocked");
- }
-
- private static bool GetProductsPrefix(Action __3)
- {
- // A private server has no Neon/GPG commerce identity. Treat price
- // prefetch as complete so startup can continue without contacting the
- // production payment API. No purchase result or currency is forged.
- __3?.Invoke();
- return false;
- }
-
- private static bool LocalPurchasePrefix(string __0, object __1, Action __2)
- {
- // Product 9100033 is the infinite-reroll confirmation. The
- // local server grants the last preview through CashShopBuy without
- // contacting Neon/GPG. No other real-money product is authorized.
- if (__0 == "brd2_limited_pack_660" || __0 == "brd2_limited_pack_660_ios")
- {
- object result = new gamfs.Platform.PurchaseData(__0, 1L,
- "bd2-local-free-infinite", "bd2-local-free-receipt");
- Log?.LogInfo("Approved local/free infinite-reroll confirmation");
- (__1 as Delegate)?.DynamicInvoke(result);
- return false;
- }
- Log?.LogWarning("Blocked unsupported paid product: " + (__0 ?? ""));
- __2?.Invoke();
- return false;
- }
-
- private static bool FinishLocalPurchasePrefix(string __0)
- {
- if (__0 == "bd2-local-free-infinite")
- {
- Log?.LogInfo("Finished local/free infinite-reroll confirmation");
- return false;
- }
- return true;
- }
-
-
}
diff --git a/plugins/LocalIdentity/Plugin.cs b/plugins/LocalIdentity/Plugin.cs
index e66fb41..e3b2cc4 100644
--- a/plugins/LocalIdentity/Plugin.cs
+++ b/plugins/LocalIdentity/Plugin.cs
@@ -61,7 +61,6 @@ public sealed class Plugin : BaseUnityPlugin
TryInstall("performance overlay suppression", () => ClientPresentation.InstallPerformanceOverlaySuppression(harmony));
TryInstall("maintenance timeout guard", () => ClientDiagnostics.InstallMaintenanceTimeoutGuard(harmony, introUI));
TryInstall("age-gate persistence", () => LocalAccountPolicy.InstallAgeGatePersistence(harmony));
- TryInstall("local purchase bypass", () => LocalAccountPolicy.InstallLocalPurchaseBypass(harmony));
TryInstall("database diagnostics", () => ClientDiagnostics.InstallDatabaseDiagnostics(harmony));
Logger.LogInfo("Local identity active: AppManager.IsPlatformLogin => false");
}
diff --git a/plugins/LoginUI/LoginController.cs b/plugins/LoginUI/LoginController.cs
index d2ed93b..4e0510e 100644
--- a/plugins/LoginUI/LoginController.cs
+++ b/plugins/LoginUI/LoginController.cs
@@ -45,11 +45,12 @@ internal static class LoginController
internal static void ClearPCLocalDataPostfix()
{
+ Log?.LogInfo("event='Saved login clearing' reason=pc-local-data-cleared" + SessionDiagnostics.Context());
AccessTokens.Clear();
EstablishedGameSession = false;
RuntimeProbeFailures = 0;
ServerInstanceID = null;
- DeleteCurrentRefresh();
+ DeleteCurrentRefresh("pc-local-data-cleared");
}
internal static bool SendMaintenancePrefix(object __instance, bool __0)
@@ -101,7 +102,7 @@ internal static class LoginController
}
catch (Exception ex)
{
- Log?.LogError("Could not request the server authentication policy: " + ex);
+ Log?.LogError("Authentication policy request failed: exception=" + ex.GetType().Name);
return false;
}
}
@@ -121,7 +122,7 @@ internal static class LoginController
}
if (!request.Success)
{
- Log?.LogWarning("Authentication policy request failed: " + request.Error);
+ Log?.LogWarning("Authentication policy request failed: http=" + request.StatusCode);
bool transient = request.StatusCode == 0 || request.StatusCode == 429 || request.StatusCode >= 500;
if (transient && attempt < 2)
{
@@ -199,23 +200,35 @@ internal static class LoginController
return;
}
FinishRecovery(false, "no usable credential is available");
- ShowLoginPanel(introUI);
+ ShowLoginPanel(introUI, "recovery-secure-store-unsupported");
return;
}
- if (ServerLoginPreferences.IsAutoLogin(NormalizedServerOrigin()) &&
- ServerLoginPreferences.UseAutoLoginPC(NormalizedServerOrigin()) &&
- CanAttemptAutomaticLogin())
+ string origin = NormalizedServerOrigin();
+ bool automatic = ServerLoginPreferences.IsAutoLogin(origin);
+ bool checkbox = ServerLoginPreferences.UseAutoLoginPC(origin);
+ bool supported = RefreshCredentials.IsSupported;
+ bool automaticKeyPresent = PlayerPrefs.HasKey(ServerLoginPreferences.Key("BD2LoginV1_IsAutoLogin_", origin));
+ bool checkboxKeyPresent = PlayerPrefs.HasKey(ServerLoginPreferences.Key("BD2LoginV1_StandaloneAutoLogin_", origin));
+ bool contains = CanAttemptAutomaticLogin(out string inspectionReason);
+ Log?.LogInfo("event='Automatic login decision' origin=" + origin +
+ " autoLogin=" + automatic + " useAutoLoginPC=" + checkbox +
+ " autoLoginKeyPresent=" + automaticKeyPresent + " useAutoLoginPCKeyPresent=" + checkboxKeyPresent +
+ " secureStoreSupported=" + supported + " credentialPresent=" + contains +
+ " inspection=" + inspectionReason);
+ if (automatic && checkbox && contains)
{
LoginInProgress = true;
StartIntroCoroutine(introUI, RefreshSession(introUI));
}
else
{
- ShowLoginPanel(introUI);
+ ShowLoginPanel(introUI, !supported ? "secure-store-unsupported" :
+ !automatic ? (automaticKeyPresent ? "auto-login-disabled" : "auto-login-setting-missing") :
+ !checkbox ? (checkboxKeyPresent ? "pc-auto-login-disabled" : "pc-auto-login-setting-missing") : inspectionReason);
}
}
- private static void ShowLoginPanel(object introUI)
+ private static void ShowLoginPanel(object introUI, string reason)
{
// The official manual-login branch cancels the 10-second startup watchdog.
// Browser authentication waits for the user and must not retain that timer.
@@ -226,7 +239,7 @@ internal static class LoginController
ConfigureLoginPanel(introUI);
Type stateType = SetIntroState.GetParameters()[0].ParameterType;
SetIntroState.Invoke(introUI, new[] { Enum.ToObject(stateType, 1) });
- Log?.LogInfo("Waiting for server-authorized third-party authentication");
+ Log?.LogInfo("event='Login required' reason=" + reason + " origin=" + NormalizedServerOrigin());
}
internal static void OpenLogin(object introUI, string provider)
@@ -255,7 +268,7 @@ internal static class LoginController
}
catch (Exception ex)
{
- Log?.LogError("Could not start " + provider + " authentication: " + ex.Message);
+ Log?.LogError("Could not start " + provider + " authentication: exception=" + ex.GetType().Name);
}
}
@@ -273,7 +286,7 @@ internal static class LoginController
if (!request.Success)
{
LoginInProgress = false;
- Log?.LogError("Could not create login transaction: " + request.Error);
+ Log?.LogError("Could not create login transaction: http=" + request.StatusCode);
yield break;
}
DeviceStart start;
@@ -319,7 +332,7 @@ internal static class LoginController
if (!request.Success)
{
LoginInProgress = false;
- Log?.LogError("Login transaction failed: " + request.Error);
+ Log?.LogError("Login transaction failed: http=" + request.StatusCode);
yield break;
}
TokenResult result;
@@ -387,7 +400,7 @@ internal static class LoginController
}
else
{
- DeleteCurrentRefresh();
+ DeleteCurrentRefresh("interactive-auto-login-disabled");
result.refresh_token = null;
}
AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in);
@@ -399,10 +412,10 @@ internal static class LoginController
{
result.access_token = null;
result.refresh_token = null;
- Log?.LogError("Could not finish interactive login: " + ex.Message);
+ Log?.LogError("event='Interactive login failed' reason=completion-failed exception=" + ex.GetType().Name);
AccessTokens.Clear();
LoginInProgress = false;
- ShowLoginPanel(introUI);
+ ShowLoginPanel(introUI, "interactive-completion-failed");
}
};
try
@@ -429,6 +442,7 @@ internal static class LoginController
yield break;
}
InvalidDataException invalid = null;
+ string invalidReason = "saved-credential-invalid";
Exception transient = null;
try
{
@@ -437,10 +451,12 @@ internal static class LoginController
catch (InvalidDataException ex)
{
invalid = ex;
+ invalidReason = ex.Data["login_reason"] as string ?? invalidReason;
}
catch (FileNotFoundException ex)
{
invalid = new InvalidDataException("saved automatic-login credential was not found", ex);
+ invalidReason = "credential-missing";
}
catch (Exception ex)
{
@@ -448,22 +464,22 @@ internal static class LoginController
}
if (invalid != null)
{
- Log?.LogError("Saved automatic login is invalid: " + invalid.Message);
- ClearSavedLogin();
+ Log?.LogError("event='Refresh failed' reason=" + invalidReason + " exception=" + invalid.GetType().Name);
+ ClearSavedLogin(invalidReason);
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
{
FinishRecovery(false, "saved automatic-login credential is invalid", generation);
}
- ShowLoginPanel(introUI);
+ ShowLoginPanel(introUI, invalidReason);
yield break;
}
if (transient != null)
{
- Log?.LogWarning("Secure automatic-login storage is temporarily unavailable: " + transient.Message);
+ Log?.LogWarning("event='Refresh failed' reason=secure-store-unavailable exception=" + transient.GetType().Name);
if (Volatile.Read(ref SessionRecoveryInProgress) == 0)
{
LoginInProgress = false;
- ShowLoginPanel(introUI);
+ ShowLoginPanel(introUI, "secure-store-unavailable");
yield break;
}
yield return new WaitForSecondsRealtime(2f);
@@ -489,25 +505,25 @@ internal static class LoginController
bool credentialRejected = (request.StatusCode == 401 || request.StatusCode == 409) && request.RefreshInvalid;
if (credentialRejected)
{
- Log?.LogWarning("Automatic-login credential rejected: HTTP=" + request.StatusCode +
+ Log?.LogWarning("event='Refresh failed' reason=credential-rejected http=" + request.StatusCode +
", refreshInvalid=" + request.RefreshInvalid + ", classification=invalid-credential");
refreshToken = null;
attemptID = null;
- ClearSavedLogin();
+ ClearSavedLogin("refresh-credential-rejected");
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
{
FinishRecovery(false, "saved automatic-login credential was rejected", generation);
}
- ShowLoginPanel(introUI);
+ ShowLoginPanel(introUI, "refresh-credential-rejected");
yield break;
}
if (!request.Success)
{
- Log?.LogWarning("Automatic login temporarily unavailable; the same refresh attempt will be retried: " + request.Error);
+ Log?.LogWarning("event='Refresh failed' reason=http-failure http=" + request.StatusCode);
if (Volatile.Read(ref SessionRecoveryInProgress) == 0)
{
LoginInProgress = false;
- ShowLoginPanel(introUI);
+ ShowLoginPanel(introUI, "refresh-http-failure");
yield break;
}
}
@@ -521,7 +537,7 @@ internal static class LoginController
}
catch (Exception ex)
{
- Log?.LogWarning("Automatic login returned an unreadable response; the same refresh attempt will be retried: " + ex.GetType().Name);
+ Log?.LogWarning("event='Refresh failed' reason=unreadable-response http=" + request.StatusCode + " exception=" + ex.GetType().Name);
}
if (ValidRefreshResult(result) && ProviderEnabled(result.provider))
{
@@ -530,6 +546,7 @@ internal static class LoginController
StoreRefresh(result);
if (result.access_expires_in <= 30)
{
+ Log?.LogInfo("event='Refresh retry' reason=access-token-near-expiry http=" + request.StatusCode);
result.access_token = null;
saved = PrepareRefreshAttempt();
refreshToken = saved.pending_refresh_token;
@@ -549,11 +566,11 @@ internal static class LoginController
{
result.access_token = null;
result.refresh_token = null;
- Log?.LogWarning("Could not persist the rotated automatic-login credential; the committed attempt will be retrieved again: " + ex.Message);
+ Log?.LogWarning("event='Refresh failed' reason=rotation-storage-failed http=" + request.StatusCode + " exception=" + ex.GetType().Name);
if (Volatile.Read(ref SessionRecoveryInProgress) == 0)
{
LoginInProgress = false;
- ShowLoginPanel(introUI);
+ ShowLoginPanel(introUI, "refresh-rotation-storage-failed");
yield break;
}
}
@@ -563,11 +580,11 @@ internal static class LoginController
yield break;
}
}
- Log?.LogWarning("Automatic login returned incomplete credentials; the same refresh attempt will be retried");
+ Log?.LogWarning("event='Refresh failed' reason=incomplete-credentials http=" + request.StatusCode);
if (Volatile.Read(ref SessionRecoveryInProgress) == 0)
{
LoginInProgress = false;
- ShowLoginPanel(introUI);
+ ShowLoginPanel(introUI, "refresh-incomplete-credentials");
yield break;
}
}
@@ -601,19 +618,23 @@ internal static class LoginController
owner.StartCoroutine(routine ?? throw new ArgumentNullException(nameof(routine)));
}
- private static bool CanAttemptAutomaticLogin()
+ private static bool CanAttemptAutomaticLogin(out string reason)
{
if (!RefreshCredentials.IsSupported)
{
+ reason = "secure-store-unsupported";
return false;
}
try
{
- return RefreshCredentials.Contains(NormalizedServerOrigin());
+ bool contains = RefreshCredentials.Contains(NormalizedServerOrigin());
+ reason = contains ? "credential-present" : "credential-missing";
+ return contains;
}
catch (Exception ex)
{
- Log?.LogWarning("Could not inspect the secure automatic-login credential: " + ex.Message);
+ reason = "credential-inspection-failed";
+ Log?.LogWarning("event='Credential inspection failed' exception=" + ex.GetType().Name);
return false;
}
}
@@ -630,6 +651,7 @@ internal static class LoginController
expires_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + result.refresh_expires_in
};
RefreshCredentials.Save(origin, credential);
+ Log?.LogInfo("event='Credential stored' reason=refresh-rotation origin=" + origin);
credential.refresh_token = null;
result.refresh_token = null;
}
@@ -642,11 +664,16 @@ internal static class LoginController
!ProviderEnabled(credential.provider) || string.IsNullOrEmpty(credential.refresh_token) ||
credential.expires_at <= DateTimeOffset.UtcNow.ToUnixTimeSeconds())
{
+ string reason = credential == null ? "credential-missing" :
+ credential.expires_at <= DateTimeOffset.UtcNow.ToUnixTimeSeconds() ? "saved-credential-expired" : "saved-credential-invalid";
+ Log?.LogWarning("event='Refresh failed' reason=" + reason);
if (credential != null)
{
credential.refresh_token = null;
}
- throw new InvalidDataException("saved automatic-login credential is invalid, expired, or belongs to another server");
+ var invalid = new InvalidDataException("saved automatic-login credential is invalid, expired, or belongs to another server");
+ invalid.Data["login_reason"] = reason;
+ throw invalid;
}
return credential;
}
@@ -663,6 +690,7 @@ internal static class LoginController
credential.pending_attempt_id = System.Guid.NewGuid().ToString("N");
credential.pending_refresh_token = credential.refresh_token;
RefreshCredentials.Save(origin, credential);
+ Log?.LogInfo("event='Credential stored' reason=refresh-attempt-prepared origin=" + origin);
}
return credential;
}
@@ -685,27 +713,31 @@ internal static class LoginController
return Encoding.UTF8.GetBytes("{\"refresh_token\":\"" + token + "\",\"attempt_id\":\"" + attemptID + "\"}");
}
- private static void ClearSavedLogin()
+ private static void ClearSavedLogin(string reason)
{
+ Log?.LogInfo("event='Saved login clearing' reason=" + reason + SessionDiagnostics.Context());
AccessTokens.Clear();
ServerLoginPreferences.SetAutoLogin(NormalizedServerOrigin(), false);
ServerLoginPreferences.SetUseAutoLoginPC(NormalizedServerOrigin(), false);
- DeleteCurrentRefresh();
+ DeleteCurrentRefresh(reason);
}
- private static void DeleteCurrentRefresh()
+ private static void DeleteCurrentRefresh(string reason)
{
if (ServerRoot == null || RefreshCredentials == null || !RefreshCredentials.IsSupported)
{
+ Log?.LogInfo("event='Credential deletion skipped' reason=" + reason + " originAvailable=" + (ServerRoot != null) +
+ " secureStoreSupported=" + (RefreshCredentials != null && RefreshCredentials.IsSupported));
return;
}
try
{
RefreshCredentials.Delete(NormalizedServerOrigin());
+ Log?.LogInfo("event='Credential deleted' reason=" + reason + " origin=" + NormalizedServerOrigin());
}
catch (Exception ex)
{
- Log?.LogWarning("Could not delete the secure automatic-login credential: " + ex.Message);
+ Log?.LogWarning("event='Credential deletion failed' reason=" + reason + " exception=" + ex.GetType().Name);
}
}
diff --git a/plugins/LoginUI/Plugin.cs b/plugins/LoginUI/Plugin.cs
index daf6188..b44f8cb 100644
--- a/plugins/LoginUI/Plugin.cs
+++ b/plugins/LoginUI/Plugin.cs
@@ -23,6 +23,7 @@ public sealed class Plugin : BaseUnityPlugin
private void OnApplicationQuit()
{
+ ApplicationQuitting = true;
DisposeGameRelay();
PlatformControlHttp.Shutdown();
}
@@ -91,6 +92,7 @@ public sealed class Plugin : BaseUnityPlugin
}
Harmony harmony = new Harmony(Guid);
+ SessionDiagnostics.Install(harmony);
harmony.Patch(awake, postfix: new HarmonyMethod(typeof(SessionRecovery), nameof(IntroAwakePostfix)));
HarmonyMethod maintenancePrefix = new HarmonyMethod(typeof(LoginController), nameof(SendMaintenancePrefix));
maintenancePrefix.after = new[] { "bd2.localidentity" };
diff --git a/plugins/LoginUI/SecureGameRelay.cs b/plugins/LoginUI/SecureGameRelay.cs
index 141f896..7fabf71 100644
--- a/plugins/LoginUI/SecureGameRelay.cs
+++ b/plugins/LoginUI/SecureGameRelay.cs
@@ -142,9 +142,9 @@ internal sealed class SecureGameRelay : IDisposable
// The native hard deadline leaves margin inside Unity's 30s timeout.
response = await PlatformControlHttp.Send(remote, start[0], body, null, 25, lifetime.Token,
lifetime.Token, 64 * 1024 * 1024, headers).ConfigureAwait(false);
- if (maintenance || response.StatusCode == 0)
+ if (maintenance || response.StatusCode == 0 || response.StatusCode >= 400)
log?.LogInfo("Game relay upstream result: request_id=" + response.RequestId + " status=" + response.StatusCode +
- " failure=" + (response.FailureKind ?? "none"));
+ " path=" + remote.AbsolutePath + " failure=" + (response.FailureKind ?? "none"));
if (response.StatusCode == 0 || response.Data == null)
{
// Return a complete, recognizable local failure. The game sends all
diff --git a/plugins/LoginUI/SessionDiagnostics.cs b/plugins/LoginUI/SessionDiagnostics.cs
new file mode 100644
index 0000000..46a6379
--- /dev/null
+++ b/plugins/LoginUI/SessionDiagnostics.cs
@@ -0,0 +1,144 @@
+using System;
+using System.Diagnostics;
+using System.Globalization;
+using System.Reflection;
+using BD2.GameNames;
+using BDNetwork;
+using HarmonyLib;
+using UnityEngine;
+
+using static Bd2LoginUI.LoginRuntime;
+using static Bd2LoginUI.SessionRecovery;
+
+namespace Bd2LoginUI;
+
+// Diagnostics never log response bodies, cookies, credentials or URL queries.
+internal static class SessionDiagnostics
+{
+ private const BindingFlags All = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
+ private static string LastFailure;
+ private static float LastFailureTime;
+ private static string LastResponseError;
+ private static float LastResponseErrorTime;
+
+ internal static void Install(Harmony harmony)
+ {
+ // Keep diagnostic binding failures separate from functional login hooks.
+ TryPatch(harmony, typeof(NetworkManager), "ResponseCheck", nameof(ResponsePrefix), 4);
+ TryPatch(harmony, typeof(NetworkManager), "ServerNetworkError", nameof(ServerErrorPrefix), 2);
+ TryPatch(harmony, typeof(NetworkManager), "OpenPacketErrorPopup", nameof(PacketPopupPrefix), 3);
+ TryPatch(harmony, typeof(AppManager), "AppReStart", nameof(RestartPrefix), 0);
+ }
+
+ private static void TryPatch(Harmony harmony, Type type, string name, string callback, int parameters)
+ {
+ try
+ {
+ MethodInfo target = type.GetGameMethod(name, All);
+ if (target == null || target.GetParameters().Length != parameters) throw new MissingMethodException();
+ harmony.Patch(target, prefix: new HarmonyMethod(typeof(SessionDiagnostics), callback));
+ }
+ catch (Exception error)
+ {
+ Log?.LogWarning("Session diagnostic binding unavailable: target=" + type.Name + "." + name + " exception=" + error.GetType().Name);
+ }
+ }
+
+ internal static void Record(string failure)
+ {
+ try
+ {
+ LastFailure = failure;
+ LastFailureTime = Time.realtimeSinceStartup;
+ Log?.LogWarning("Session diagnostic: " + failure + Context(false));
+ }
+ catch { /* A diagnostic must never interrupt the client's error handling. */ }
+ }
+
+ internal static string Context(bool includeFailure = true)
+ {
+ try
+ {
+ string recent = LastFailure == null ? "none" : LastFailure;
+ string age = LastFailure == null ? "none" : Math.Max(0, Time.realtimeSinceStartup - LastFailureTime).ToString("F1", CultureInfo.InvariantCulture);
+ return " origin=" + (ServerRoot?.GetLeftPart(UriPartial.Authority) ?? "unset") +
+ " established=" + EstablishedGameSession + " recovering=" + (SessionRecoveryInProgress != 0) +
+ " generation=" + RecoveryGeneration +
+ (LastResponseError == null ? "" : " recent_response_error_age_s=" + Math.Max(0, Time.realtimeSinceStartup - LastResponseErrorTime).ToString("F1", CultureInfo.InvariantCulture) + " recent_response_error={" + LastResponseError + "}") +
+ (includeFailure ? " recent_failure_age_s=" + age + " recent_failure={" + recent + "}" : "");
+ }
+ catch { return " diagnostic_context=unavailable"; }
+ }
+
+ private static object Value(object target, string name)
+ {
+ if (target == null) return null;
+ Type type = target.GetType();
+ return type.GetGameField(name, All)?.GetValue(target) ?? type.GetGameProperty(name, All)?.GetValue(target, null);
+ }
+
+ private static void ResponsePrefix(string __0, object __1, object __2)
+ {
+ try
+ {
+ object code = Value(__2, "errorType");
+ if (code == null || Convert.ToInt32(code) == 0) return;
+ LastResponseError = "path=" + Path(__0) + " sequence=" + Value(__1, "Sequence") +
+ " packet_code=" + Value(__2, "packetCode") + " error_code=" + code + " reason=" + ErrorReason(code);
+ LastResponseErrorTime = Time.realtimeSinceStartup;
+ Record("event=game-response-error " + LastResponseError);
+ }
+ catch (Exception error) { Log?.LogWarning("Session response diagnostic unavailable: exception=" + error.GetType().Name); }
+ }
+
+ internal static string Code(object value)
+ {
+ try { return Convert.ToInt32(value).ToString(CultureInfo.InvariantCulture); }
+ catch { return "unknown"; }
+ }
+
+ internal static string Path(string value) => (value ?? "unknown").Split('?', '#')[0].Replace('\r', ' ').Replace('\n', ' ');
+
+ private static void ServerErrorPrefix(object __0) => Record("event=server-network-error error_code=" + Code(__0) + " reason=" + ErrorReason(__0));
+
+ private static void PacketPopupPrefix(int __0, int __1, bool __2) => Record(
+ "event=packet-error-popup packet_code=" + __0 + " error_code=" + __1 +
+ " text_id=" + (100000L + __0 * 100L + __1) + " restart_on_confirm=" + __2);
+
+ internal static string ErrorReason(object error)
+ {
+ try
+ {
+ if (error == null) return "unknown";
+ int code = Convert.ToInt32(error);
+ // Resolve enum labels through the shared name table, including renamed fields.
+ foreach (FieldInfo field in typeof(EErrorType).GetFields(BindingFlags.Public | BindingFlags.Static))
+ if (Convert.ToInt32(field.GetRawConstantValue()) == code)
+ {
+ foreach (string name in new[] { "SESSION_DST", "DB_CLOSE", "QUERY_ERROR", "WAIT_TIMEOUT", "REQUEST_FAILED", "NOT_PINGCHECK_SESSION_DST", "WAIT_PROCESS_REDIRECT", "RPE_NEON_API_ACCESS_TOKEN_EXPIRE", "PRE_NETWORK_ERROR_NEON_API", "CLINET_NET_TIMEOUT", "CLIENT_LOGIC_ERROR", "RPE_UPDATE_MAJOR_CLIENT_DOWN", "RPE_CDN_ASSET_BUNDLE_UPDATE", "RPE_PURCHASED_DISABLED", "RPE_BLOCK_IP", "PRE_BLOCK_NETWORK_TYPE", "RPE_REQUEST_MISSING_PARAM", "RPE_XML_DATA_NOT_FOUND", "RPE_PROTOBUF_ENCODING_ERROR" })
+ if (field.Name == Game.MemberName(typeof(EErrorType), name, GameMemberKind.Field)) return name;
+ }
+ return "unclassified";
+ }
+ catch { return "unclassified"; }
+ }
+
+ private static void RestartPrefix()
+ {
+ try
+ {
+ // Method identities only; stack arguments and exception text may contain credentials.
+ var callers = new System.Text.StringBuilder();
+ foreach (StackFrame frame in new StackTrace(false).GetFrames() ?? Array.Empty())
+ {
+ MethodBase method = frame.GetMethod();
+ if (method?.DeclaringType == null || method.DeclaringType == typeof(SessionDiagnostics)) continue;
+ if (callers.Length > 0) callers.Append(" <- ");
+ callers.Append(method.DeclaringType.FullName).Append('.').Append(method.Name);
+ if (callers.Length > 1200) break;
+ }
+ Log?.LogWarning("Client restart requested:" + Context() + " callers=" + callers);
+ }
+ catch { /* Preserve the original restart even if stack inspection fails. */ }
+ }
+}
diff --git a/plugins/LoginUI/SessionRecovery.cs b/plugins/LoginUI/SessionRecovery.cs
index c155750..88002bb 100644
--- a/plugins/LoginUI/SessionRecovery.cs
+++ b/plugins/LoginUI/SessionRecovery.cs
@@ -20,6 +20,7 @@ namespace Bd2LoginUI;
internal static class SessionRecovery
{
internal static int SessionRecoveryInProgress;
+ internal static bool ApplicationQuitting;
private static RecoveryHost Owner;
private static SecureGameRelay GameRelay;
private static readonly List> GameRequests = new List>();
@@ -113,7 +114,12 @@ internal static class SessionRecovery
{
Lifetime.Cancel();
Lifetime.Dispose();
- Log?.LogWarning("Login recovery coroutine host was destroyed");
+ if (ApplicationQuitting)
+ Log?.LogInfo("Login recovery host stopped: reason=application-quit");
+ else if (!EstablishedGameSession && Volatile.Read(ref SessionRecoveryInProgress) == 0)
+ Log?.LogInfo("Login recovery host destroyed before game login; it will be recreated on IntroUI initialization");
+ else
+ Log?.LogWarning("Login recovery host unexpectedly destroyed:" + SessionDiagnostics.Context());
}
}
@@ -155,6 +161,17 @@ internal static class SessionRecovery
{
return;
}
+ try
+ {
+ if (request.result != UnityWebRequest.Result.Success || request.responseCode >= 400)
+ SessionDiagnostics.Record("event=game-http-failure path=" + requestUri.AbsolutePath +
+ " http_status=" + request.responseCode + " result=" + request.result +
+ " access_expired=" + (request.GetResponseHeader("X-BD2-Access-Expired") == "1") +
+ " session_expired=" + (request.GetResponseHeader("X-BD2-Session-Expired") == "1") +
+ " reconnect=" + (request.GetResponseHeader("X-BD2-Reconnect") == "1") +
+ " transport_failure=" + (request.GetResponseHeader("X-BD2-Transport-Failure") == "1"));
+ }
+ catch { /* Keep actual HTTP recovery independent of diagnostics. */ }
if (request.result == UnityWebRequest.Result.Success && request.responseCode >= 200 && request.responseCode < 300)
LastGameSuccess = Time.realtimeSinceStartup;
else if (IsGameTransportFailure(request))
@@ -213,12 +230,12 @@ internal static class SessionRecovery
EnsureRecoveryHost();
if (Interlocked.CompareExchange(ref SessionRecoveryInProgress, 1, 0) == 0)
{
- Log?.LogWarning("Starting automatic game-session recovery: " + reason);
+ Log?.LogWarning("Starting automatic game-session recovery: reason=" + reason + SessionDiagnostics.Context());
ShowRecoveryOverlay("正在重新连接服务器……\nReconnecting to server…");
}
else
{
- Log?.LogWarning("Restarting automatic game-session recovery: " + reason);
+ Log?.LogWarning("Restarting automatic game-session recovery: reason=" + reason + SessionDiagnostics.Context());
}
ScheduleRecoveryRestart();
return true;
@@ -230,10 +247,23 @@ internal static class SessionRecovery
}
}
- internal static bool ExponentialBackoffPrefix(object __0)
+ internal static bool ExponentialBackoffPrefix(object __0, object __1)
{
try
{
+ try
+ {
+ if (__0 != null && IsConfiguredServerFailure(__0))
+ {
+ const BindingFlags requestFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
+ object failed = __0.GetType().GetGameProperty("PacketData", requestFlags)?.GetValue(__0, null);
+ string path = failed?.GetType().GetGameProperty("SendPath", requestFlags)?.GetValue(failed, null) as string;
+ object observedRetries = failed?.GetType().GetGameProperty("RetryCount", requestFlags)?.GetValue(failed, null);
+ SessionDiagnostics.Record("event=game-request-backoff path=" + SessionDiagnostics.Path(path) + " retry_count=" + observedRetries +
+ " error_code=" + SessionDiagnostics.Code(__1) + " reason=" + SessionDiagnostics.ErrorReason(__1));
+ }
+ }
+ catch { /* Diagnostics must not change backoff or recovery decisions. */ }
try
{
const BindingFlags diagnosticFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
@@ -525,8 +555,9 @@ internal static class SessionRecovery
internal static bool SuppressNetworkErrorDuringRecovery(object __0, object __1)
{
- Log?.LogWarning("Client network error: type=" + __0 + ", code=" + __1 +
- ", recovering=" + (Volatile.Read(ref SessionRecoveryInProgress) != 0));
+ SessionDiagnostics.Record("event=client-network-error error_code=" + SessionDiagnostics.Code(__0) +
+ " reason=" + SessionDiagnostics.ErrorReason(__0) + " service_code=" + SessionDiagnostics.Code(__1) +
+ " suppressed=" + (Volatile.Read(ref SessionRecoveryInProgress) != 0));
return Volatile.Read(ref SessionRecoveryInProgress) == 0;
}
diff --git a/plugins/README.md b/plugins/README.md
index 5b1bded..18661bd 100644
--- a/plugins/README.md
+++ b/plugins/README.md
@@ -4,9 +4,10 @@
- `LocalIdentity/`:连接根目录 `versions.json` 所选客户端与本地服务器;构建产物为 `BD2LocalIdentity.dll`。
- `LoginUI/`:把客户端登录面板收敛为 Discord 与 Google 两个本服认证入口;构建产物为 `BD2LoginUI.dll`。
+- `CashShop/`:把现金商品改为服务端核算的付费钻石购买;充值商品遵守 `game.json` 的兑换配置,产物为 `BD2CashShop.dll`。
- `CaptureEnvironment/`:独立原版对照客户端抓包使用;构建产物为 `BD2CaptureEnvironment.dll`。
-`LocalIdentity` 与 `LoginUI` 用于本地服客户端;`CaptureEnvironment` 用于独立原版抓包环境。原版抓包环境不得安装 `BD2LocalIdentity.dll` 或 `BD2LoginUI.dll`。
+`LocalIdentity`、`LoginUI` 与 `CashShop` 用于本地服客户端;`CaptureEnvironment` 用于独立原版抓包环境。原版抓包环境不得安装 `BD2LocalIdentity.dll`、`BD2LoginUI.dll` 或 `BD2CashShop.dll`。
客户端、资源与插件版本都来自根目录 `versions.json`;MSBuild 在中间目录生成 C# 常量,源码不保存第二份版本值。
SDK 和共享运行时已内嵌当前游戏版本的同一份名字表。源码构建只需客户端目录,无需提供官方映射。`.NET 8 SDK` 构建工具;插件为 `netstandard2.1`,运行时为 `netstandard2.0`。构建自动生成可读引用、完整源码和内嵌源码 PDB,编译插件、执行 reobf 并验证真实 DLL 的引用。首次生成耗时数分钟,三个插件和 Debug/Release 共用本机缓存;IDE 转到定义可以查看方法体,`Game Sources` 文件夹提供全量浏览与搜索。IDE 设置见 [完整源码导航](GameSdk/README.md#完整源码导航)。
@@ -18,6 +19,7 @@ SDK 和共享运行时已内嵌当前游戏版本的同一份名字表。源码
```powershell
dotnet build .\plugins\LocalIdentity\LocalIdentity.csproj -c Release -p:GameDir="<本地服客户端目录>"
dotnet build .\plugins\LoginUI\LoginUI.csproj -c Release -p:GameDir="<本地服客户端目录>"
+dotnet build .\plugins\CashShop\CashShop.csproj -c Release -p:GameDir="<本地服客户端目录>"
dotnet build .\plugins\CaptureEnvironment\CaptureEnvironment.csproj -c Release -p:GameDir="<原版客户端目录>"
```
diff --git a/schedules/cash_shop.bd2schedule b/schedules/cash_shop.bd2schedule
new file mode 100644
index 0000000..3e31f72
Binary files /dev/null and b/schedules/cash_shop.bd2schedule differ
diff --git a/schedules/event_hubs.bd2schedule b/schedules/event_hubs.bd2schedule
new file mode 100644
index 0000000..6d3b7e1
Binary files /dev/null and b/schedules/event_hubs.bd2schedule differ
diff --git a/schedules/events.bd2schedule b/schedules/events.bd2schedule
new file mode 100644
index 0000000..5d562eb
Binary files /dev/null and b/schedules/events.bd2schedule differ
diff --git a/schedules/gacha.bd2schedule b/schedules/gacha.bd2schedule
new file mode 100644
index 0000000..857bff2
Binary files /dev/null and b/schedules/gacha.bd2schedule differ
diff --git a/schedules/monster_hunt.bd2schedule b/schedules/monster_hunt.bd2schedule
new file mode 100644
index 0000000..f6ad278
Binary files /dev/null and b/schedules/monster_hunt.bd2schedule differ
diff --git a/schedules/regular.bd2schedule b/schedules/regular.bd2schedule
new file mode 100644
index 0000000..cbe0384
Binary files /dev/null and b/schedules/regular.bd2schedule differ
diff --git a/tools/python/start-official-capture.ps1 b/tools/python/start-official-capture.ps1
index 2a64936..71fb7b1 100644
--- a/tools/python/start-official-capture.ps1
+++ b/tools/python/start-official-capture.ps1
@@ -123,7 +123,7 @@ foreach ($unsafePlugin in @($identityPlugin, $loginUIPlugin)) {
}
}
-foreach ($unsafeName in @('BD2LocalIdentity.dll', 'BD2LoginUI.dll')) {
+foreach ($unsafeName in @('BD2LocalIdentity.dll', 'BD2LoginUI.dll', 'BD2CashShop.dll')) {
$unexpectedPlugin = Get-ChildItem -LiteralPath (Join-Path $GameDir 'BepInEx') `
-Recurse -File -Filter $unsafeName -ErrorAction SilentlyContinue
if ($unexpectedPlugin) {
diff --git a/versions.json b/versions.json
index eaa5755..38f3bca 100644
--- a/versions.json
+++ b/versions.json
@@ -8,6 +8,7 @@
"plugins": {
"local_identity": "0.6.1",
"capture_environment": "0.2.0",
- "login_ui": "0.2.0"
+ "login_ui": "0.2.0",
+ "cash_shop": "0.1.0"
}
}